Appearance
实现一个简单的倒计时钟表
说明
本次想通过实现一个倒计时的时钟来练习TimelineView以及Canvas的使用。首先时间一个倒计时的选择页面,选择需要倒计时的时间。然后实现一个时钟的样式,用来倒计式。最后实现倒计时结束的提示页面。
倒计时选择页面
整个倒计时选择界面比较简单,如下图所示

整个页面是在一个NavigationView中,因为SwiftUI中的DatePicker不支持精确到秒,所以需要自己实验一个,这里直接从网上抄来一个,具体的代码可以参考这里:
swift
VStack{
PickerView(seconds: $countDownSec)
Button("start"){
self.startDate = .now
}.disabled(countDownSec==0)
}
倒计计选择页面是overlay到时钟页面上面的,当点击start后,设置了开始时间,刚overlay界面消失
时钟界面及结束页面
时间页面比较简单,如下图所示,一个文字的倒计时加上一个时钟。时钟上面倒计时的区域以红色为底,指针会随着倒计时的时间而移动,这里为了简单起见,只实现了分针的移动,整个页面的更新通过TimelineView来驱动。 
具体的代码如下,时间耗尽前显示当前剩余的秒数以及时钟,耗尽后显示结果界面。
swift
TimelineView(.animation) { context in
VStack{
if startDate != .distantPast {
let timeRemain = (Double(countDownSec) - context.date.timeIntervalSince(startDate))/60
if timeRemain > 0{
Text("\(Int(timeRemain*60))").padding(.vertical).font(.largeTitle)
CountdownAnalogView(countDown: timeRemain)
}else{
Text("time is over").font(.largeTitle.bold())
Button("reset"){
self.startDate = .distantPast
self.countDownSec = 0
}.buttonStyle(.borderedProminent).padding(.vertical)
}
}
}
时钟界面的绘制
整个CountdownAnalogView是是一个Canvas,首先以整个画面为中心,以长宽中小边取一个正方形,同时时钟留一定的边距,以绘制时的offset。
swift
Canvas { gContext, size in
// 1
let clockSize = min(size.width, size.height) * 0.9
// 2
let centerOffsetX = min(size.width, size.height) * 0.05 + (size.width - min(size.width, size.height))/2
let centerOffsetY = min(size.width, size.height) * 0.05 + (size.height - min(size.width, size.height))/2
// 3
let clockCenterX = size.width / 2.0
let clockCenterY = size.height / 2.0
然后确定时钟的内外边,在内外边两个正方形内分别画圆
swift
// 4
let frameRect = CGRect(
x: centerOffsetX,
y: centerOffsetY,
width: clockSize,
height: clockSize)
let outframeRect = CGRect(
x: centerOffsetX-10,
y: centerOffsetY-10 ,
width: clockSize+20,
height: clockSize+20)
gContext.withCGContext { cgContext in
// 2
cgContext.setStrokeColor(UIColor.black.cgColor)
// 3
cgContext.setFillColor(UIColor.brown.cgColor)
cgContext.setLineWidth(5.0)
// 4
cgContext.addEllipse(in: outframeRect)
// 5
cgContext.drawPath(using: .fillStroke)
cgContext.setFillColor(dayColor)
cgContext.setLineWidth(2.0)
cgContext.addEllipse(in: frameRect)
// 5
cgContext.drawPath(using: .fillStroke)
然后将画布的原点设置到时钟的中心方便我们后续操作,然后分别绘制剩余时间的扇形区域,以及刻度表和时针以及分针
swift
cgContext.setFillColor(UIColor.black.cgColor)
// 2
cgContext.translateBy(x: clockCenterX, y: clockCenterY)
// 3
let angle = 12 / 12.0 * 2 * Double.pi
let hourRadius = clockSize * 0.65 / 2.0
let minuteRadius = clockSize * 0.75 / 2.0
let minuteAngle = countDown / 60.0 * 2 * Double.pi
addCountDownArc(context: cgContext, angle: minuteAngle, width: 0, length: clockSize/2*0.85)
drawTickMarks(
gcontext: gContext,
context: cgContext,
size: clockSize,
offset: CGPoint(x: 0, y: 0))
// 4
drawClockHand(
context: cgContext,
angle: angle,
width: 7.5,
length: hourRadius)
drawClockHand(
context: cgContext,
angle: minuteAngle,
width: 5.0,
length: minuteRadius)
扇形区域比较简单,通过cgcontext绘制addArc来确定扇形的边,然后填充一个半透明的红色
swift
func addCountDownArc( context: CGContext,
angle: Double,
width: Double,
length: Double){
context.saveGState()
context.setFillColor(UIColor.red.cgColor)
context.setAlpha(0.7)
context.move(to: CGPoint(x: 0, y: 0))
context.rotate(by: -Double.pi/2)
context.addArc(center: CGPoint(x: 0, y: 0), radius: length, startAngle: 0, endAngle: angle, clockwise: false)
context.fillPath()
context.restoreGState()
}
绘制刻度表,通过三角函数分别计算需要刻度的坐标,每5分钟一个长刻度,每1分钟一个短刻度。在绘制长刻度时,同时把文字也绘制上
swift
func drawTickMarks(gcontext: GraphicsContext,context: CGContext, size: Double, offset : CGPoint) {
// 1
let clockCenterX = offset.x
let clockCenterY = offset.y
let clockRadius = size / 2.0
context.saveGState()
context.setStrokeColor(UIColor.gray.cgColor)
context.setLineWidth(1.5)
for minitMark in 0..<60 {
// 3
let angle = Double(minitMark) / 60.0 * 2.0 * Double.pi
// 4
let startX = cos(angle) * clockRadius + clockCenterX
let startY = sin(angle) * clockRadius + clockCenterY
// 5
let endX = cos(angle) * clockRadius * 0.95 + clockCenterX
let endY = sin(angle) * clockRadius * 0.95 + clockCenterY
// 6
context.move(to: CGPoint(x: startX, y: startY))
// 7
context.addLine(to: CGPoint(x: endX, y: endY))
// 8
context.strokePath()
}
context.restoreGState()
// 2
for hourMark in 0..<12 {
// 3
let angle = Double(hourMark) / 12.0 * 2.0 * Double.pi
// 4
let startX = cos(angle) * clockRadius + clockCenterX
let startY = sin(angle) * clockRadius + clockCenterY
// 5
let endX = cos(angle) * clockRadius * 0.9 + clockCenterX
let endY = sin(angle) * clockRadius * 0.9 + clockCenterY
// 6
context.move(to: CGPoint(x: startX, y: startY))
// 7
context.addLine(to: CGPoint(x: endX, y: endY))
// 8
context.strokePath()
let tX = cos(angle) * clockRadius * 0.8
let tY = sin(angle) * clockRadius * 0.8
var hour = (hourMark+3)%12
hour = (hour == 0 ? 12 : hour) * 5
/*gcontext.draw(Text("\(hour)").font(.title.bold()).foregroundColor(.secondary), at: CGPoint(x: tX,y: tY))*/
draw("\(hour)",in: context, at: CGPoint(x: tX,y: tY))
}
}
然后是绘制时针分针,先旋转坐标系,然后另边上横坐标上下文绘制两条线
swift
func drawClockHand(
context: CGContext,
angle: Double,
width: Double,
length: Double
) {
// 1
context.saveGState()
// 2
context.rotate(by: angle)
// 3
context.move(to: CGPoint(x: 0, y: 0))
context.addLine(to: CGPoint(x: -width, y: -length * 0.67))
context.addLine(to: CGPoint(x: 0, y: -length))
context.addLine(to: CGPoint(x: width, y: -length * 0.67))
context.closePath()
// 4
context.fillPath()
// 5
context.restoreGState()
}
至此整个工作就完成了,因为实现的比较粗糙,所以还有很多细节没有完善。理解情况下,选择时间可以通过拨动时间上的分针来实现,待后续再进行补充。