本节摘要:系统控件库再全,也长不出"按浇水进度变色的圆环"。自定义视图是自育品种的路:继承 UIView,重写 draw 方法,用 Core Graphics 的画笔(路径、描边、填充)直接作画。本节讲 draw 的触发纪律(只通过 setNeedsDisplay 请求)、贝塞尔路径三件套(线、弧、填充)、进度环的完整实现,以及 draw 的性能代价与替代方案——该画时才画,该省时用图层。
详情页想要一个圆环:外圈灰色轨道,内圈按"距下次浇水还剩几天"的比例填充蓝色,中间写百分比。翻遍系统控件——进度条是直的,仪表盘太重,都不对味。这就是自定义视图的出场时机:当视觉由数学定义而非内容决定时,画比摆更直接。
先立三条铁律,再看代码:
final class ProgressRingView: UIView { var progress: CGFloat = 0 { // 0...1 浇水进度 didSet { setNeedsDisplay() } // 属性一变就申请重画(铁律二) } var ringColor: UIColor = .systemBlue var trackColor: UIColor = .systemGray5 override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear isOpaque = false // 透明背景,圆环外不挡人 contentMode = .redraw // 尺寸变化时也重画(配合 bounds 重绘) } required init?(coder: NSCoder) { fatalError("纯代码") } override func draw(_ rect: CGRect) { let center = CGPoint(x: bounds.midX, y: bounds.midY) let radius = min(bounds.width, bounds.height) / 2 - 10 // 留 10 点线宽余量 let lineWidth: CGFloat = 10 // 第一层:灰色轨道(整圈) let track = UIBezierPath(arcCenter: center, radius: radius, startAngle: -.pi / 2, // 十二点方向起 endAngle: .pi * 1.5, // 顺时针转满一整圈 clockwise: true) trackColor.setStroke() track.lineWidth = lineWidth track.lineCapStyle = .round track.stroke() // 描边画出轨道 // 第二层:进度弧(按 progress 截取) let sweep = .pi * 2 * progress let arc = UIBezierPath(arcCenter: center, radius: radius, startAngle: -.pi / 2, endAngle: -.pi / 2 + sweep, clockwise: true) ringColor.setStroke() arc.lineWidth = lineWidth arc.lineCapStyle = .round // 端点圆头,收笔不生硬 arc.stroke() // 第三层:中心文字(百分比) let text = "\(Int(progress * 100))%" as NSString let attrs: [NSAttributedString.Key: Any] = [ .font: UIFont.boldSystemFont(ofSize: radius * 0.5), .foregroundColor: UIColor.darkGray ] let size = text.size(withAttributes: attrs) text.draw(at: CGPoint(x: center.x - size.width / 2, y: center.y - size.height / 2), withAttributes: attrs) } }
使用与输出:
let ring = ProgressRingView(frame: CGRect(x: 0, y: 0, width: 160, height: 160)) view.addSubview(ring) ring.progress = 0.62 // 环填充到 62%,中心显示 62% // 每日一跳:进度推进时只改属性,didSet 自动申请重画 ring.progress = 0.75 // 下一帧画面变为 75%,无闪烁

Core Graphics 的世界只有一支笔(路径 UIBezierPath)与三种用法——描边、填充、裁剪。圆环用了弧与描边,补全另外两样:
// 线段:养护折线图(最近七天浇水量) let chart = UIBezierPath() chart.move(to: CGPoint(x: 20, y: 100)) // 落笔 chart.addLine(to: CGPoint(x: 60, y: 40)) // 第一段 chart.addLine(to: CGPoint(x: 100, y: 70)) chart.addLine(to: CGPoint(x: 140, y: 30)) UIColor.systemTeal.setStroke() chart.lineWidth = 3 chart.stroke() // 输出:一条四点三段的折线 // 填充:折线下方铺色(视觉上是面积图) let area = chart.copy() as! UIBezierPath area.addLine(to: CGPoint(x: 140, y: 100)) // 沿底边合拢 area.addLine(to: CGPoint(x: 20, y: 100)) UIColor.systemTeal.withAlphaComponent(0.2).setFill() area.fill()
坐标系提醒(3.2 的延续):draw 里的原点在本视图 bounds 左上角,y 轴向下——折线图"数值越大画得越高"要自己做 y = height - value 的翻转,这是图表代码的经典第一步。
背景:详情页的环要反映"剩余天数占比",且每天回来都要对。操作:把模型翻译成 progress,接 3.3 的生命周期刷新。
final class PlantDetailViewController: UIViewController { private let ring = ProgressRingView(frame: CGRect(x: 0, y: 0, width: 160, height: 160)) private var plant: Plant override func viewDidLoad() { super.viewDidLoad() view.addSubview(ring) ring.center = view.center // 简化示意,正式用约束(3.5) renderRing() } override func viewWillAppear(_ animated: Bool) { // 每次回来重算(3.3:每次刷新进这里) super.viewWillAppear(animated) renderRing() } private func renderRing() { let ratio = 1 - CGFloat(plant.passedDays) / CGFloat(max(plant.intervalDays, 1)) ring.progress = min(max(ratio, 0), 1) // 钳位到 0...1,防除零 print("环进度更新:\(Int(ring.progress * 100))%") // 输出示例:环进度更新:33% } }
结果:进度随天数衰减,浇水后回到满环。解读:钳位那行是防御性代码——passedDays 可能超过 intervalDays(忘了浇),不钳位弧长会画过一圈穿帮;renderRing 挂在 viewWillAppear,保证从别的页面回来时环是新的(数据变了必须有人申请重画,铁律二的完整闭环)。变式:环的配色随紧急度切换(剩余三成以下变橙、见底变红),在 renderRing 里一并设 ringColor;再进一步,4.3 的拖动手势已能改 progress——视图、手势、绘制三件套此刻合体。
⚠️ 常见坑:在 draw 里创建图片解码、读文件。draw 每帧都在跑,这些重活会直接拖掉帧率——重资源在别处备好存成属性,draw 只负责"把准备好的东西画出来"。
UIKit 的器官全齐了。下一章换一种长法——SwiftUI 把这些器官全部声明式重写。