几个建议...
首先,不要对大小/位置值进行硬编码,而是将 HalfCircleProgressView 保持在 2:1 比率,并使您的弧线完全适合视图:
然后您可以将该视图嵌入到“容器”中:
并相对于 HalfCircleProgressView 放置其他元素(标签等):
并将背景设置为清除:
接下来,使用 CAShapeLayer 而不是覆盖 draw():
- 白色“背景”弧的一层
- 红色“前景”弧的一层
- 一层用于“刻度线”
使用 CAShapeLayer 有几个优点——对于您的特定需求,最大的一个是我们可以使用 .strokeEnd 属性“自动”管理前景弧。
因此,如果背景和前景“弧形”图层使用相同的路径和线宽属性:
当我们想将红色“进度”弧设置为 25% 时,我们设置:
foregroundLayer.strokeEnd = 0.25
对于 80%:
foregroundLayer.strokeEnd = 0.8
那么,“刻度线”呢?
好吧,看起来你想要标记(我猜):
210° / 230° / 250° / 270° / 290° / 310° / 330°
我们不会在前景弧上使用遮罩图案,而是使用带有线段的贝塞尔曲线路径作为我们“刻度线”形状层的.cgPath。
形状图层路径在“中线上”描边,因此我们需要循环遍历“间隙”步骤,计算 15 点厚弧的“外边缘”上的角度点,计算角度点“内边缘”上的角度,然后是.move(to:)和.addLine(to:)。
我们可以为这些计算写一些数学...或者,我们可以“欺骗”并利用 UIBezierPath 的 .currentPoint 属性!
当我们像这样操作路径时:
let bez = UIBezierPath()
bez.move(to: pt)
bez.addArc(withCenter: c, radius: r, startAngle: a1, endAngle: a2, clockwise: true)
我们可以通过以下方式获得该弧的“终点”:
bez.currentPoint
因此,为了添加线段,我们将创建一个半径为radius PLUS lineWidth * 0.5 的“outerPath”和一个半径为radius MINUS lineWidth * 0.5 的“innerPath”。然后我们以 .addArc 开始每条路径到第一个刻度线的角度并循环七次(对于 7 个刻度线)...每次通过循环递增角度并添加线段:
// 20-degree tick spacing
let angleInc: CGFloat = .pi / 9.0
var angle: CGFloat = .pi * 1.5 - angleInc * 4.0
for _ in 1...7 {
tickInnerPath.addArc(withCenter: center, radius: tickInnerRadius, startAngle: angle, endAngle: angle + angleInc, clockwise: true)
tickOuterPath.addArc(withCenter: center, radius: tickOuterRadius, startAngle: angle, endAngle: angle + angleInc, clockwise: true)
tickPath.move(to: tickInnerPath.currentPoint)
tickPath.addLine(to: tickOuterPath.currentPoint)
angle += angleInc
}
一直到第 7 个循环:
当然,我们不会看那些额外的路径:
所以我们的自定义视图现在在 100% 时看起来像这样:
33%:
78.27%:
这是您可以使用的一些示例代码:
class HalfCircleProgressView: UIView {
public var progress: CGFloat = 0.0 {
didSet {
// keep progress between 0.0 and 1.0
progress = max(0.0, min(1.0, progress))
// update layer stroke end
foregroundLayer.strokeEnd = progress
}
}
public func setProgress(_ v: CGFloat, animated: Bool) {
CATransaction.begin()
if !animated {
// disable CALayer "built-in" animation
CATransaction.setDisableActions(true)
}
self.progress = v
CATransaction.commit()
}
private let backgroundLayer = CAShapeLayer()
private let foregroundLayer = CAShapeLayer()
private let ticksLayer = CAShapeLayer()
private let lineWidth: CGFloat = 15
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
// properties common to all layers
[backgroundLayer, foregroundLayer, ticksLayer].forEach { lay in
lay.fillColor = UIColor.clear.cgColor
layer.addSublayer(lay)
}
backgroundLayer.strokeColor = UIColor.white.cgColor
foregroundLayer.strokeColor = UIColor.red.cgColor
ticksLayer.strokeColor = UIColor.white.cgColor
backgroundLayer.lineWidth = lineWidth
foregroundLayer.lineWidth = lineWidth
ticksLayer.lineWidth = 2.0
}
override func layoutSubviews() {
super.layoutSubviews()
let center: CGPoint = CGPoint(x: bounds.midX, y: bounds.maxY)
let w: CGFloat = bounds.width - lineWidth
let h: CGFloat = bounds.height - lineWidth * 0.5
let radius: CGFloat = min(w * 0.5, h)
let startAngle: CGFloat = .pi
let endAngle: CGFloat = 0.0
let path = UIBezierPath(arcCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
backgroundLayer.path = path.cgPath
foregroundLayer.path = path.cgPath
let tickInnerRadius: CGFloat = radius - lineWidth * 0.5
let tickOuterRadius: CGFloat = radius + lineWidth * 0.5
let tickInnerPath = UIBezierPath()
let tickOuterPath = UIBezierPath()
let tickPath = UIBezierPath()
// 20-degree tick spacing
let angleInc: CGFloat = .pi / 9.0
// start at 270-degrees minus 4 * spacing
var angle: CGFloat = .pi * 1.5 - angleInc * 4.0
for _ in 1...7 {
tickInnerPath.addArc(withCenter: center, radius: tickInnerRadius, startAngle: angle, endAngle: angle + angleInc, clockwise: true)
tickOuterPath.addArc(withCenter: center, radius: tickOuterRadius, startAngle: angle, endAngle: angle + angleInc, clockwise: true)
tickPath.move(to: tickInnerPath.currentPoint)
tickPath.addLine(to: tickOuterPath.currentPoint)
angle += angleInc
}
ticksLayer.path = tickPath.cgPath
foregroundLayer.strokeEnd = progress
}
}
以及一个带有一些“百分比按钮”的示例控制器和一个用于更改进度百分比的滑块:
class HalfCircleTestVC: UIViewController {
let hcpView = HalfCircleProgressView()
// add a label to show the progress percent
let pctLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
// we want the arc to be inset a bit, so we'll embed it in a "container"
let container = UIView()
container.backgroundColor = .systemYellow
container.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(container)
hcpView.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(hcpView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
container.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
container.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
container.heightAnchor.constraint(equalTo: container.widthAnchor, multiplier: 0.6),
container.centerYAnchor.constraint(equalTo: g.centerYAnchor),
// let's inset our progress view by 40-points on each side
hcpView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 40.0),
hcpView.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -40.0),
// give hcpView a 2:1 ratio
hcpView.heightAnchor.constraint(equalTo: hcpView.widthAnchor, multiplier: 1.0 / 2.0),
hcpView.centerYAnchor.constraint(equalTo: container.centerYAnchor),
])
pctLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(pctLabel)
// add the pctLabel in the middle of the arc
NSLayoutConstraint.activate([
pctLabel.centerXAnchor.constraint(equalTo: hcpView.centerXAnchor),
pctLabel.centerYAnchor.constraint(equalTo: hcpView.centerYAnchor),
])
// let's add some percent / progress buttons
let btnStack = UIStackView()
btnStack.spacing = 4
btnStack.distribution = .fillEqually
[0.0, 0.1, 0.25, 0.33, 0.61, 0.8, 1.0].forEach { v in
let b = UIButton()
b.setTitle("(v)", for: [])
b.setTitleColor(.white, for: .normal)
b.setTitleColor(.lightGray, for: .highlighted)
b.titleLabel?.font = .systemFont(ofSize: 13.0, weight: .bold)
b.backgroundColor = .systemGreen
b.layer.cornerRadius = 6
b.addTarget(self, action: #selector(btnTapped(_:)), for: .touchUpInside)
btnStack.addArrangedSubview(b)
}
btnStack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(btnStack)
// let's add a slider to set the progress
let slider = UISlider()
slider.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(slider)
NSLayoutConstraint.activate([
btnStack.topAnchor.constraint(equalTo: container.bottomAnchor, constant: 20.0),
btnStack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
btnStack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
slider.topAnchor.constraint(equalTo: btnStack.bottomAnchor, constant: 20.0),
slider.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
slider.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
])
slider.addTarget(self, action: #selector(sliderChanged(_:)), for: .valueChanged)
sliderChanged(slider)
container.backgroundColor = .lightGray
}
func updatePercentLabel() {
let pct = hcpView.progress
pctLabel.text = String(format: "%0.2f %%", pct * 100.0)
}
@objc func btnTapped(_ sender: UIButton) {
if let t = sender.currentTitle {
let v = (t as NSString).floatValue
// set progress directly
// this will use CALayer "built-in" animation
hcpView.progress = CGFloat(v)
updatePercentLabel()
}
}
@objc func sliderChanged(_ sender: UISlider) {
// we want to update the progress WHILE dragging the slider
// so, set progress WITHOUT animation
// otherwise, we get a "lag"
hcpView.setProgress(CGFloat(sender.value), animated: false)
updatePercentLabel()
}
}
运行时它看起来像这样: