#29 模仿 iOS Clock App - 3 :Stopwatch
模仿 iOS Clock App系列:
繼上一篇完成鬧鐘功能後,這次接續完成碼錶的功能。
實現功能
- 實現碼錶的 start,stop,lap(分段),reset 功能。
- 已分段的時間會顯示最長與最短的時間。
- App 進入背景,再回到前景時,碼錶顯示的數字要包含在背景的時間。
- 水平滑動切換以數字和圓形時鐘顯示的畫面。
數字和類比時鐘畫面切換
利用Scroll View 和Container View完成可水平滑動切換
- 利用scroll view實現水平滑動,要把Page Enabled打開才能整頁切換。
- 加入水平排列的stack view,stack view加入兩個container view。
- container view分別連接到數字時鐘和類比時鐘。
細節步驟可參考彼得潘的教學
Scroll view的 page indicator
@IBOutlet weak var containerIndicator: UIPageControl!
var pageNumber: Int = 0
用UIScrollViewDelegate的scrollViewDidScroll方法,偵測scroll view滑動後的行為。
利用內容移動的X距離/整個scroll view寬度便可以得到頁面整數。
extension StopwatchViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
pageNumber = Int(stopWatchScrollView.contentOffset.x / stopWatchScrollView.bounds.size.width)
containerIndicator.currentPage = pageNumber
}
}
記得指定scroll view代理
override func viewDidLoad() {
super.viewDidLoad()
stopWatchScrollView.delegate = self
傳遞資料給透過Container view連結view controller
重點來了!如何在parent view將資料傳遞給child view?只要透過seague連接的view就可以使用到func prepare的功能來傳遞資料。
首先建立 兩個view controller的變數
private var digitViewController: DigitViewController?
private var analogViewController: AnalogViewController?
在與兩個container view連接的segue identifier分別命名為digitTimeSegue和analogTimeSegue。接著將個別segue終點連到的controller指定為digitViewController和analogViewController。
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "digitTimeSegue", let controller = segue.destination as? DigitViewController {
digitViewController = controller
}
if segue.identifier == "analogTimeSegue", let controller = segue.destination as? AnalogViewController {
analogViewController = controller
}
這樣就可以在parent controller控制container view的controller了。
計時功能
開始計時
經過研究理解後,利用Date()建立時間戳記來計算時間差,會比只使用timer來加數值累計時間來的精準,尤其是app到背景後timer可能會不精準。
所以當按下開始按鈕變建立mainStartTime,利用 mainTimeInterval = -(mainStartTime?.timeIntervalSinceNow ?? 0) 可以計算與目前時間的時間差。
另外要按下暫停後,利用mainRestingTimeeInterval = mainRestingTimeeInterval + Date().timeIntervalSince(mainStartTime ?? Date()) 紀錄當下的時間差。
所以重新按下開始按鈕後,mainStartTime會更新為當下的時間,再加上mainRestingTimeeInterval就可以繼續暫停前的時間了。
也因為要排除暫停的時間,mainTimeInterval的程式要更新為
mainTimeInterval = -(mainStartTime?.timeIntervalSinceNow ?? 0) + mainRestingTimeeInterval
class StopwatchViewController: UIViewController {
var isReset = true //是否清空
var isRunning = false //是否正在計時var mainStartTime: Date? //按下開始時的時間
var mainTimeInterval: TimeInterval = 0 //按下開始到目前的時間差
var mainRestingTimeeInterval: TimeInterval = 0 //按下暫停到重新開始的時間差
var mainTimeString: String {
按下開始時便啟動func start()與裡面的timer,將要即時變化的時間計算寫在closure裡
func start() {
isReset = false
isRunning = true
mainStartTime = Date()
lapStartTime = Date()
lapLabel.text = "Lap \(lapIndex)"
timer = Timer.scheduledTimer(withTimeInterval: 0.0, repeats: true, block: { [self](_) in
mainTimeInterval = -(mainStartTime?.timeIntervalSinceNow ?? 0) + mainRestingTimeeInterval
lapTimeInterval = -(lapStartTime?.timeIntervalSinceNow ?? 0) + lapRestingTimeeInterval
timeLabel.text = lapTimeString
})
}
停止計時
func stop() {
mainRestingTimeeInterval = mainRestingTimeeInterval + Date().timeIntervalSince(mainStartTime ?? Date())
lapRestingTimeeInterval = lapRestingTimeeInterval + Date().timeIntervalSince(lapStartTime ?? Date())//
isRunning = false
timer.invalidate()
}
Lap分段時間
struct Lap: Codable {
let lapIndex: Int
let lapTimeString: String
let lapTime: TimeInterval
}
當碼錶開始跑動時按下Lap會將目前的lap記錄下來,並重新重新開始計算新Lap時間。
func setLap(){
lapStartTime = Date()
lapRestingTimeeInterval = 0
laps.insert(Lap(lapIndex: lapIndex, lapTimeString: lapTimeString, lapTime: lapTimeInterval), at: 0)
lapIndex += 1
lapLabel.text = "Lap \(lapIndex)"
tableView.reloadData()
比較時間長短
紀錄最長與最短的時間差,用於lap清單裡顯示最長與最短的分段
var maxLapTime: TimeInterval = 0
var minLapTime: TimeInterval = TimeInterval(Int.max)maxLapTime = lapTimeInterval > maxLapTime ? lapTimeInterval : maxLapTime
minLapTime = lapTimeInterval < minLapTime ? lapTimeInterval : minLapTime
}
顯示時間分段紀錄
extension StopwatchViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
laps.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "\(StopwatchTableViewCell.self)", for: indexPath) as! StopwatchTableViewCell
let lap = laps[indexPath.row]
cell.lapLabel.text = "Lap \(lap.lapIndex)"
cell.timeLabel.text = lap.lapTimeString
if laps.count > 1 {
if lap.lapTime >= maxLapTime {
cell.lapLabel.textColor = .lightRed
cell.timeLabel.textColor = .lightRed
}else if lap.lapTime <= minLapTime {
cell.lapLabel.textColor = .lightGreen
cell.timeLabel.textColor = .lightGreen
}else {
cell.lapLabel.textColor = .white
cell.timeLabel.textColor = .white
}
}
return cell
}
}
時鐘顯示
數字時鐘
//顯示目前的時間字串
digitViewController?.digitTimeLabel.text = mainTimeString
類比時鐘
//顯示目前的時間字串
analogViewController?.digitTimeLabel.text = mainTimeString //目前主要的時間差,提供指針旋轉的數值計算
analogViewController?.mainTimeInterval = mainTimeInterval //目前分段的時間差,提供指針旋轉的數值計算analogViewController?.lapTimeInterval = lapTimeInterval
analogViewController?.lapWatchView.isHidden = laps.isEmpty ? true : false
指針旋轉
建立以下三個個別包含分針、秒針與分段指針繪圖的view。
- mainMinuteWatchView
- mainSecondWatchView
- lapWatchView
控制指針旋轉的指令如下,至於指針繪圖的部分這裡就不贅述了。
class AnalogViewController: UIViewController {
var center: CGPoint?
var mainTimeInterval: TimeInterval = 0 {
didSet {
let secondAngle = CGFloat(mainTimeInterval.truncatingRemainder(dividingBy: 60) * 6)
mainSecondWatchView.transform = CGAffineTransform.identity.rotated(by: secondAngle.degree)
let minuteAngle = CGFloat(mainTimeInterval / 60 * 6)
mainMinuteWatchView.transform = CGAffineTransform.identity.rotated(by: minuteAngle.degree)
}
}
var lapTimeInterval: TimeInterval = 0 {
didSet {
let secondAngle = CGFloat(lapTimeInterval.truncatingRemainder(dividingBy: 60) * 6)
lapWatchView.transform = CGAffineTransform.identity.rotated(by: secondAngle.degree)
}
}