#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)
}
}

--

--