這篇文章主要和大家一起探討下如何實(shí)現(xiàn)蘋果手機(jī)上自帶的秒表功能。在實(shí)現(xiàn)之前筐喳,對(duì)這個(gè)話題感興趣的也可以先跑一跑自己手機(jī)上的秒表蓄氧,分析一下功能,看看自己能不能自主實(shí)現(xiàn)堂氯。如果可以,也希望能在實(shí)現(xiàn)完之后再看我這篇文章晶框,你的實(shí)現(xiàn)方式和我的實(shí)現(xiàn)方式有哪些地方不同?誰的更節(jié)約性能一些呢?以及在細(xì)節(jié)上的處理,有沒有更好的方式漱抓?歡迎與我探討檐迟。
如果覺得實(shí)現(xiàn)有一些難度追迟,那么主要卡在了哪些不知道該怎么處理的實(shí)現(xiàn)上束铭,可以帶著疑惑看這篇文章,希望能給你一些思考和幫助。
當(dāng)然也可以一邊參考我的代碼實(shí)現(xiàn)一邊往下看会通,這個(gè)項(xiàng)目我有傳到github上煤辨,有swift和oc兩個(gè)版本。如果我有講述不清楚的地方且轨,可以參考代碼實(shí)現(xiàn)泳挥,傳送門:stopWatch 希望能幫到你。本篇講述中的代碼使用swift語言嫌变,那么新建一個(gè)項(xiàng)目东涡,和我一起開始吧。
功能分析
以下是操作秒表的視頻,可以先看下胧辽,然后再具體分析我們要實(shí)現(xiàn)的功能。
首先進(jìn)行頁面布局的分析凡蚜,從視頻來看人断,比較好確定該使用的控件。頂部使用一個(gè)大的label來實(shí)時(shí)刷新時(shí)間朝蜘。中間是兩個(gè)button恶迈。底部可以使用一個(gè)tabelview來記錄計(jì)次的時(shí)間。如圖所示谱醇,我是使用stroybord來搭建的界面暇仲。
拖拽控件,及按鈕的點(diǎn)擊事件到controller中:
@IBOutlet weak var tableView: UITableView!
@IBOutlet weak var timeLable: UILabel!
@IBOutlet weak var resetButton: WSButton!
@IBOutlet weak var startButton: WSButton!
// 復(fù)位/計(jì)次
@IBAction func reset(_ sender: WSButton) {
}
// 開始/停止
@IBAction func start(_ sender: WSButton) {
}
然后具體分析一下操作的邏輯副渴。當(dāng)點(diǎn)擊右邊的啟動(dòng)按鈕后奈附,頂部的label開始跑動(dòng) 注意按鈕底部也會(huì)同步開始跑動(dòng),但這兩個(gè)時(shí)間是不同的煮剧,頂部的lable是總時(shí)間斥滤,按鈕下面刷新的是此次計(jì)次的時(shí)間。當(dāng)開始啟動(dòng)之后勉盅,點(diǎn)擊右邊的按鈕開始計(jì)次佑颇,會(huì)重新生成一個(gè)計(jì)次,又從零開始跑菇篡。那么之前的計(jì)次就結(jié)束了漩符,并且在tableview中刷新顯示出來。此時(shí)啟動(dòng)按鈕轉(zhuǎn)化成暫停按鈕驱还,點(diǎn)擊這個(gè)按鈕嗜暴,停止計(jì)時(shí)凸克。停止計(jì)時(shí)后,左邊的計(jì)次按鈕由計(jì)次轉(zhuǎn)化為復(fù)位闷沥,點(diǎn)擊復(fù)位萎战,所有記錄的時(shí)間都清零,回歸到原始的狀態(tài)舆逃。具體狀態(tài)如下所示:
初始狀態(tài)蚂维,此時(shí)點(diǎn)擊計(jì)次按鈕無效。因?yàn)槲撮_始啟動(dòng)路狮,無法計(jì)次虫啥。
正在運(yùn)行的狀態(tài),點(diǎn)擊計(jì)次可以開始計(jì)次奄妨。
暫停狀態(tài)涂籽,點(diǎn)擊啟動(dòng)可以恢復(fù)運(yùn)行。
所記錄的時(shí)間當(dāng)中砸抛,最長(zhǎng)的時(shí)間用紅色標(biāo)識(shí)评雌,最短的時(shí)間用綠色標(biāo)識(shí)。
總結(jié)一下整體的狀態(tài)變化:
- 初始狀態(tài)下直焙,也就是未啟動(dòng)之前景东,是無法計(jì)次的,此時(shí)點(diǎn)擊計(jì)次按鈕無效奔誓。
- 當(dāng)點(diǎn)擊啟動(dòng)后斤吐,左邊按鈕此時(shí)可以開始計(jì)次,啟動(dòng)按鈕也會(huì)變成停止按鈕厨喂。
- 當(dāng)點(diǎn)擊停止按鈕后曲初,計(jì)次按鈕變成復(fù)位按鈕,停止按鈕變成啟動(dòng)按鈕杯聚。
- 點(diǎn)擊復(fù)位按鈕,一切回歸初始狀態(tài)抒痒。
- 在列表所計(jì)次的所有時(shí)間中幌绍,最長(zhǎng)的時(shí)間用紅色標(biāo)識(shí),最短的時(shí)間用綠色標(biāo)識(shí)故响。
模型分析
項(xiàng)目中我是使用的mvc模式傀广,所以分析下model。新建一個(gè)模型類彩届,命名為stopwatchData,讓我們看看這個(gè)類該怎么寫伪冰。
首先需要使用一個(gè)數(shù)組來存儲(chǔ)所有計(jì)次的時(shí)間,我的思路上樟蠕,這里也包括正在計(jì)次的時(shí)間贮聂。以及用一個(gè)bool值來記錄當(dāng)前是否是復(fù)位狀態(tài)靠柑。注意這里是privite(set),只允許內(nèi)部設(shè)置,外部是無法設(shè)置的吓懈。
private(set) public var times:[Int] = []
private(set) var isReset:Bool = true
基于要突出最大時(shí)間與最小時(shí)間歼冰,改變文字顏色使其顯眼。所以這里我也要計(jì)算出最大時(shí)間與最小時(shí)間耻警,存入模型隔嫡。但時(shí)間是顯示在tableview上的,所有我這里只需記錄下最大與最小時(shí)間所在數(shù)組(times)中的索引甘穿,我在tableview中即可根據(jù)索引來做出相應(yīng)的改變腮恩。同樣這個(gè)兩個(gè)屬性是read-only的,外界只需訪問就行温兼。
這里有一點(diǎn)需要重點(diǎn)說一下秸滴,因?yàn)閿?shù)組(times)里面包括正在運(yùn)行的時(shí)間,這個(gè)時(shí)間還沒結(jié)束確定下來妨托,所以是不參與計(jì)算的缸榛。所以數(shù)組的個(gè)數(shù)必須要2個(gè)以上才開始計(jì)算最大或最小計(jì)次時(shí)間。
public var maxTimeIndex:Int? {
get {
if times.count <= 2 {
return nil;
}
var maxIndex = 1
for index in 2..<times.count {
if(times[index] > times[maxIndex]){
maxIndex = index
}
}
return maxIndex
}
}
public var minTimeIndex:Int? {
get {
if times.count <= 2 {
return nil;
}
var minIndex = 1
for index in 2..<times.count {
if(times[index] < times[minIndex]){
minIndex = index
}
}
return minIndex
}
}
另外兰伤,當(dāng)我們點(diǎn)擊復(fù)位按鈕時(shí)内颗,需要重置數(shù)據(jù),所以寫一個(gè)reset()來重置數(shù)據(jù)敦腔。里面很簡(jiǎn)單均澳,設(shè)置isReset為true及清空數(shù)組。
public func reset() {
isReset = true
times = []
}
當(dāng)我們重新開始一次計(jì)次時(shí)符衔,數(shù)組該如何表現(xiàn)出來呢?這里我是在數(shù)組插入一個(gè)元素到首位找前,初始時(shí)間為零。當(dāng)開始計(jì)次后判族,同步更新數(shù)組首元素的值躺盛。
我在這個(gè)方法里寫了一個(gè)回調(diào),方便在重新開啟一個(gè)計(jì)時(shí)后做些事情形帮,參數(shù)默認(rèn)值為nil槽惫。
public func beginingNewTime(comletion:(() ->())? = nil){
times.insert(0, at: 0)
isReset = false
if let comletion = comletion {
comletion()
}
}
下面這個(gè)方法就比較簡(jiǎn)單了,直接更新數(shù)組首元素的值辩撑。
public func timing(time:Int) {
times[0] = time
}
這個(gè)類基本就是這些了界斜。
實(shí)現(xiàn)功能
下面看下controller里面的實(shí)現(xiàn)代碼,首先考慮控制器中需要哪些變量呢合冀?
- 需要一個(gè)定時(shí)器來刷新時(shí)間各薇,以及一個(gè)咱們之前寫的模型類()
var timer: Timer?
let data = stopWatchData()
- 需要一個(gè)totalTime來存儲(chǔ)總時(shí)間,注意當(dāng)值改變時(shí)君躺,會(huì)同步更新頂部label的值峭判。
var totalTime = 0 {
willSet{
timeLable.text = convertTime(seconds: newValue)
}
}
這里有一個(gè)方法convertTime(seconds:)开缎,因?yàn)閠otalTime存儲(chǔ)的是一個(gè)整數(shù),當(dāng)計(jì)時(shí)器每隔0.01s刷新時(shí)朝抖,totalTime 加1啥箭。所以將totalTime轉(zhuǎn)化成對(duì)應(yīng)時(shí)間的格式然后才能顯示出來,convertTime(seconds: )方法的實(shí)現(xiàn)如下所示治宣。
func convertTime(seconds: Int) ->String{
return String(format: "%02ld:%02ld.%02ld",seconds / 100 / 60, seconds / 100 % 60, seconds % 100)
}
- 存儲(chǔ)正在計(jì)次時(shí)間的變量(intervals),這個(gè)值就是啟動(dòng)時(shí)實(shí)時(shí)計(jì)次的時(shí)間急侥。因?yàn)榭偸窃趖ableview的第一行,所以這里先獲取到tableview的首行侮邀,這個(gè)獲取很簡(jiǎn)單坏怪,tableview類中有對(duì)應(yīng)的方法,取首段首行的值即可绊茧,我封裝成timingcell()方法铝宵,如下所示。RecordTimeCell是自定義的cell,稍后再講华畏。
func timingCell()->RecordTimeCell?{
return tableView.cellForRow(at: NSIndexPath(row: 0, section: 0) as IndexPath) as? RecordTimeCell
}
對(duì)應(yīng)的inervals改變時(shí)鹏秋,cell上的值同步更新。
var intervals = 0 {
willSet{
let cell = timingCell()
cell?.detailTextLabel?.text = convertTime(seconds: newValue)
}
}
然后再就是按鈕的狀態(tài)切換亡笑,啟動(dòng)與暫停侣夷,計(jì)次與復(fù)位。我使用UIButton中的selected與normal來切換仑乌。
第一個(gè)是計(jì)時(shí)/復(fù)位按鈕百拓。我是將title設(shè)為復(fù)位作為selected狀態(tài),title設(shè)為計(jì)時(shí)為normal狀態(tài)晰甚。另外衙传,當(dāng)復(fù)位之后,還沒開始計(jì)次時(shí)厕九,計(jì)次按鈕是不能點(diǎn)的蓖捶,所以還當(dāng)設(shè)置一個(gè)disable狀態(tài),并且初始化時(shí)設(shè)置isEnabled為false,不能點(diǎn)擊扁远。
resetButton.setTitle("計(jì)次", for: .disabled)
resetButton.isEnabled = false
resetButton .setBackgroundImage(createImage(UIColor.init(red: 0.08, green: 0.08, blue: 0.08, alpha: 1)), for: .disabled)
resetButton.setTitle("復(fù)位", for: .selected);
resetButton.setTitleColor(UIColor.white, for: .selected)
resetButton.setBackgroundImage(createImage(UIColor.gray), for: .selected)
resetButton.setTitle("計(jì)次", for: .normal)
resetButton.setBackgroundImage(resetButton.backgroundImage(for: .selected), for: .normal)
接下來分析下這個(gè)按鈕的點(diǎn)擊事件腺阳,實(shí)際上里面就是一個(gè)if else 判斷,來分別處理select與normal兩種狀態(tài)穿香。
1 當(dāng)是select狀態(tài)(復(fù)位)時(shí),點(diǎn)擊按鈕, resetVariables()用來重置所有的變量绎速。余下就用來改變按鈕的狀態(tài)皮获,將按鈕設(shè)置成不可點(diǎn)擊,以及取消select狀態(tài)纹冤。
2 當(dāng)按鈕是normal狀態(tài)(計(jì)次)時(shí)洒宝,點(diǎn)擊之后购公,這時(shí)會(huì)重新計(jì)算一段時(shí)間,所以將intervals 重置為0雁歌,并且調(diào)用模型中的方法beginingNewTime()宏浩,在前面有說到,調(diào)用beginNewTime方法靠瞎,會(huì)在模型中的數(shù)組首位添加一個(gè)新的元素比庄。這樣就保證了模型中的數(shù)據(jù)也在實(shí)時(shí)更新。
3 無論是點(diǎn)擊復(fù)位還是計(jì)次乏盐,最后調(diào)用tableviwtableView.reloadData()刷新數(shù)據(jù)佳窑。
@IBAction func reset(_ sender: WSButton) {
if sender.isSelected { // 復(fù)位
resetVariables()
sender.isEnabled = false
sender.isSelected = false
} else { // 計(jì)次
intervals = 0
data.beginingNewTime()
}
// 最后刷新顯示的數(shù)據(jù)
tableView.reloadData()
}
這里給出resetVariables()方法,比較簡(jiǎn)單父能。
func resetVariables(){
totalTime = 0
intervals = 0
data.reset()
}
第二個(gè)是啟動(dòng)/暫停按鈕神凑,我是將title設(shè)為啟動(dòng)作為normal狀態(tài),title設(shè)為停止時(shí)作為selected狀態(tài)何吝。
startButton.setTitle("啟動(dòng)", for: .normal)
startButton.setTitleColor(UIColor.init(red: 64 / 255.0, green: 203 / 255.0, blue: 96 / 255.0, alpha: 1), for: .normal)
startButton.setBackgroundImage(createImage(UIColor.init(red: 74 / 255.0, green: 16 / 255.0, blue: 17 / 255.0, alpha: 1)), for: .selected)
startButton.setTitle("停止", for: .selected)
startButton.setTitleColor(.red, for: .selected);
我會(huì)著重講一下這個(gè)按鈕的點(diǎn)擊事件溉委,這里面處理的邏輯或許稍微有些復(fù)雜,請(qǐng)保持耐心爱榕,我會(huì)講述的細(xì)致一些瓣喊。
這里面可以分成4部分,首行代碼加另外3個(gè)if判斷呆细。
1 首行代碼是狀態(tài)切換型宝。當(dāng)是select狀態(tài)時(shí),狀態(tài)取反絮爷,取消select狀態(tài)趴酣。當(dāng)不是select狀態(tài)時(shí),狀態(tài)取反坑夯,變成select狀態(tài)岖寞。
2 當(dāng)點(diǎn)擊的是停止時(shí)(isSelected = true),狀態(tài)取反后變成啟動(dòng)(isSelect = flase)柜蜈,那么此時(shí)切換計(jì)次/復(fù)位按鈕應(yīng)該為復(fù)位仗谆,設(shè)置resetButton.isSelected = true,變成復(fù)位的狀態(tài)淑履。然后停止掉定時(shí)器隶垮,然后再return掉,結(jié)束這個(gè)方法秘噪。
第二步處理了點(diǎn)擊停止狸吞。3~4都是要處理當(dāng)點(diǎn)擊啟動(dòng)時(shí)該做哪些操作。
3.使用模型中的isReset判斷當(dāng)前是否是復(fù)位了,如果是蹋偏,當(dāng)點(diǎn)擊啟動(dòng)之后便斥,就要調(diào)用data.beginingNewTime() 重新開啟一個(gè)計(jì)次,并且左邊的復(fù)位/計(jì)次按鈕 從不可點(diǎn)擊變成可點(diǎn)擊計(jì)次的狀態(tài)。如果不是復(fù)位威始,那么當(dāng)點(diǎn)擊啟動(dòng)后枢纠,左邊按鈕要切換成可復(fù)位的狀態(tài)。
4.第四步用來開啟定時(shí)器黎棠,并且在定時(shí)器的刷新回調(diào)里面更新數(shù)據(jù)晋渺。注意這里調(diào)用了模型中的timing(time:)方法,傳入intervals葫掉,實(shí)時(shí)更新模型些举。
@IBAction func start(_ sender: WSButton) {
// 1
sender.isSelected = !sender.isSelected
// 2
if !sender.isSelected { // 點(diǎn)擊停止
resetButton.isSelected = true
timer?.invalidate()
timer = nil
return
}
// 3
if data.isReset {
data.beginingNewTime {
self.tableView.reloadData()
self.resetButton.isEnabled = true
}
} else {
resetButton.isSelected = false
}
// 4
if timer == nil {
timer = Timer.init(timeInterval: 0.01, repeats: true, block: { [weak self] _ in
self?.totalTime += 1
self?.intervals += 1
self?.data.timing(time: self!.intervals)
})
RunLoop.current.add(self.timer!, forMode: .commonModes)
}
}
另外要說的就是tableview了,比較常規(guī)的操作俭厚,這里就不做過多介紹了户魏。值得注意的是,這里cell的樣式使用value1就可滿足挪挤。
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return data.times.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
var cell = tableView.dequeueReusableCell(withIdentifier: "cell") as? RecordTimeCell
if cell == nil {
cell = RecordTimeCell.init(style: .value1, reuseIdentifier: "cell")
}
cell?.recordTime(with: data, indexPath)
return cell!;
}
接下來看看自定義的cell,里面有一個(gè)方法recordTime(with data: indexPath:)叼丑,傳入模型以及indexPath,indexPath用來取模型數(shù)組中的數(shù)據(jù)。另外根據(jù)模型中的maxTimeIndex與minTimeIndex來確定是哪個(gè)cell,并對(duì)應(yīng)的修改cell上文本的顏色扛门。
代碼如下:
public func recordTime( with data:stopWatchData, _ indexPath:IndexPath){
textLabel?.textColor = UIColor.white
detailTextLabel?.textColor = UIColor.white
textLabel?.text = "計(jì)次\(data.times.count - indexPath.row)"
let time = data.times[indexPath.row]
detailTextLabel?.text = convertTime(seconds: time)
if indexPath.row == data.maxTime{
textLabel?.textColor = UIColor.red
detailTextLabel?.textColor = UIColor.red
}
if indexPath.row == data.minTime{
textLabel?.textColor = UIColor.green
detailTextLabel?.textColor = UIColor.green
}
}
自此功能基本上就實(shí)現(xiàn)完成了鸠信,如果能耐心看到這里,不妨再往下看看论寨,接下來要說的是項(xiàng)目中的一些細(xì)節(jié)星立,主要有三個(gè)。
1葬凳, 要設(shè)置定時(shí)器的模式為commonmode绰垂,如果不設(shè)置的話,當(dāng)滑動(dòng)tableview時(shí)火焰,會(huì)停止計(jì)時(shí)劲装。
2,我們的按鈕是一個(gè)正方形昌简,如下圖占业。我們?cè)O(shè)置cornerRadius后才變成一個(gè)圓形,手機(jī)屏幕上顯示的是如下圖的紅色區(qū)域纯赎,但如果點(diǎn)擊圓形周圍的藍(lán)色部分谦疾,還是可以響應(yīng)事件的。
那么如何才能讓這一部分藍(lán)色區(qū)域不再響應(yīng)事件呢犬金?
比較簡(jiǎn)單念恍,這里可以自定義UIBUtton,并且重寫是否響應(yīng)事件的方法碎紊。
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
if super.point(inside: point, with: event) {
let path = UIBezierPath.init(ovalIn: self.bounds)
return path.contains(point)
}
return false
}
3 第三個(gè)問題時(shí),頂部的label更新數(shù)據(jù)時(shí)會(huì)產(chǎn)生抖動(dòng)樊诺。
而蘋果手機(jī)上數(shù)字是很穩(wěn)定,不會(huì)抖動(dòng)的音同。
這個(gè)問題該如何解決呢词爬?老實(shí)說,我也思考了很久权均,我嘗試過使用NSMutableAttributeString來加大數(shù)字之間的間隔顿膨,發(fā)現(xiàn)并沒有效果。后來才發(fā)現(xiàn)叽赊,需要設(shè)置其他的字體才不會(huì)抖動(dòng)恋沃,使用系統(tǒng)默認(rèn)的字體是會(huì)抖動(dòng)的。我在項(xiàng)目中使用的是Helvetica Neue必指。
我的講述結(jié)束了囊咏。
如果還有什么疑惑,歡迎在評(píng)論區(qū)提問塔橡,也可以在github上提issue梅割,當(dāng)然我更加歡迎加我個(gè)人微信:WSAlonely。希望你能幫助我發(fā)現(xiàn)實(shí)現(xiàn)過程中的問題:)