抽丝剥茧实现苹果手机中的秒表功能

这篇文章主要和大家一起探讨下如何实现苹果手机上自带的秒表功能。在实现之前,对这个话题感兴趣的也可以先跑一跑自己手机上的秒表,分析一下功能,看看自己能不能自主实现。如果可以,也希望能在实现完之后再看我这篇文章,你的实现方式和我的实现方式有哪些地方不同?谁的更节约性能一些呢?以及在细节上的处理,有没有更好的方式?欢迎与我探讨。

如果觉得实现有一些难度,那么主要卡在了哪些不知道该怎么处理的实现上,可以带着疑惑看这篇文章,希望能给你一些思考和帮助。

当然也可以一边参考我的代码实现一边往下看,这个项目我有传到github上,有swift和oc两个版本。如果我有讲述不清楚的地方,可以参考代码实现,传送门:stopWatch 希望能帮到你。本篇讲述中的代码使用swift语言,那么新建一个项目,和我一起开始吧。

功能分析

以下是操作秒表的视频,可以先看下,然后再具体分析我们要实现的功能。

image

首先进行页面布局的分析,从视频来看,比较好确定该使用的控件。顶部使用一个大的label来实时刷新时间。中间是两个button。底部可以使用一个tabelview来记录计次的时间。如图所示,我是使用stroybord来搭建的界面。

image

拖拽控件,及按钮的点击事件到controller中:

    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var timeLable: UILabel!
    @IBOutlet weak var resetButton: WSButton!
    @IBOutlet weak var startButton: WSButton!
    
    // 复位/计次
    @IBAction func reset(_ sender: WSButton) {

    }
    
    // 开始/停止
     @IBAction func start(_ sender: WSButton) {
      
     }

然后具体分析一下操作的逻辑。当点击右边的启动按钮后,顶部的label开始跑动 注意按钮底部也会同步开始跑动,但这两个时间是不同的,顶部的lable是总时间,按钮下面刷新的是此次计次的时间。当开始启动之后,点击右边的按钮开始计次,会重新生成一个计次,又从零开始跑。那么之前的计次就结束了,并且在tableview中刷新显示出来。此时启动按钮转化成暂停按钮,点击这个按钮,停止计时。停止计时后,左边的计次按钮由计次转化为复位,点击复位,所有记录的时间都清零,回归到原始的状态。具体状态如下所示:

初始状态,此时点击计次按钮无效。因为未开始启动,无法计次。


image

正在运行的状态,点击计次可以开始计次。


image

暂停状态,点击启动可以恢复运行。


image

所记录的时间当中,最长的时间用红色标识,最短的时间用绿色标识。


image

总结一下整体的状态变化:

  • 初始状态下,也就是未启动之前,是无法计次的,此时点击计次按钮无效。
  • 当点击启动后,左边按钮此时可以开始计次,启动按钮也会变成停止按钮。
  • 当点击停止按钮后,计次按钮变成复位按钮,停止按钮变成启动按钮。
  • 点击复位按钮,一切回归初始状态。
  • 在列表所计次的所有时间中,最长的时间用红色标识,最短的时间用绿色标识。

模型分析

项目中我是使用的mvc模式,所以分析下model。新建一个模型类,命名为stopwatchData,让我们看看这个类该怎么写。

首先需要使用一个数组来存储所有计次的时间,我的思路上,这里也包括正在计次的时间。以及用一个bool值来记录当前是否是复位状态。注意这里是privite(set),只允许内部设置,外部是无法设置的。

    private(set) public var times:[Int] = []
    private(set)  var  isReset:Bool = true

基于要突出最大时间与最小时间,改变文字颜色使其显眼。所以这里我也要计算出最大时间与最小时间,存入模型。但时间是显示在tableview上的,所有我这里只需记录下最大与最小时间所在数组(times)中的索引,我在tableview中即可根据索引来做出相应的改变。同样这个两个属性是read-only的,外界只需访问就行。

这里有一点需要重点说一下,因为数组(times)里面包括正在运行的时间,这个时间还没结束确定下来,所以是不参与计算的。所以数组的个数必须要2个以上才开始计算最大或最小计次时间。

    public var maxTimeIndex:Int? {
        get {
            if times.count <= 2 {
                return nil;
            }
            
            var maxIndex = 1
            
            for index in 2.. times[maxIndex]){
                    maxIndex = index
                }
            }
            return maxIndex
        }
    }
    
    public var minTimeIndex:Int? {
        get {
            if times.count <= 2 {
                return nil;
            }
            
            var minIndex = 1
            
            for index in 2..

另外,当我们点击复位按钮时,需要重置数据,所以写一个reset()来重置数据。里面很简单,设置isResettrue及清空数组。

    public func reset() {
        isReset = true
        times = []
    }

当我们重新开始一次计次时,数组该如何表现出来呢?这里我是在数组插入一个元素到首位,初始时间为零。当开始计次后,同步更新数组首元素的值。

我在这个方法里写了一个回调,方便在重新开启一个计时后做些事情,参数默认值为nil。

    public func beginingNewTime(comletion:(() ->())? = nil){
        times.insert(0, at: 0)
        isReset = false
        
        if let comletion = comletion {
            comletion()
        }
    }

下面这个方法就比较简单了,直接更新数组首元素的值。

    public func timing(time:Int) {
        times[0] = time
    }

这个类基本就是这些了。

实现功能

下面看下controller里面的实现代码,首先考虑控制器中需要哪些变量呢?

  • 需要一个定时器来刷新时间,以及一个咱们之前写的模型类()
var timer: Timer? 
let data = stopWatchData()
  • 需要一个totalTime来存储总时间,注意当值改变时,会同步更新顶部label的值。
    var totalTime = 0 {
        willSet{
            timeLable.text = convertTime(seconds: newValue)
        }
    }

这里有一个方法convertTime(seconds:),因为totalTime存储的是一个整数,当计时器每隔0.01s刷新时,totalTime 加1。所以将totalTime转化成对应时间的格式然后才能显示出来,convertTime(seconds: )方法的实现如下所示。

    func convertTime(seconds: Int) ->String{
        return String(format: "%02ld:%02ld.%02ld",seconds / 100 / 60, seconds / 100 % 60, seconds % 100)
    }
  • 存储正在计次时间的变量(intervals),这个值就是启动时实时计次的时间。因为总是在tableview的第一行,所以这里先获取到tableview的首行,这个获取很简单,tableview类中有对应的方法,取首段首行的值即可,我封装成timingcell()方法,如下所示。RecordTimeCell是自定义的cell,稍后再讲。
    func timingCell()->RecordTimeCell?{
        return tableView.cellForRow(at: NSIndexPath(row: 0, section: 0) as IndexPath) as? RecordTimeCell
    }

对应的inervals改变时,cell上的值同步更新。

    var intervals = 0 {
        willSet{
            let cell = timingCell()
            cell?.detailTextLabel?.text = convertTime(seconds: newValue)
        }

    }

然后再就是按钮的状态切换,启动与暂停,计次与复位。我使用UIButton中的selected与normal来切换。
第一个是计时/复位按钮。我是将title设为复位作为selected状态,title设为计时为normal状态。另外,当复位之后,还没开始计次时,计次按钮是不能点的,所以还当设置一个disable状态,并且初始化时设置isEnabled为false,不能点击。

        resetButton.setTitle("计次", 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("复位", for: .selected);
        resetButton.setTitleColor(UIColor.white, for: .selected)
        resetButton.setBackgroundImage(createImage(UIColor.gray), for: .selected)
        
        resetButton.setTitle("计次", for: .normal)
        resetButton.setBackgroundImage(resetButton.backgroundImage(for: .selected), for: .normal)

接下来分析下这个按钮的点击事件,实际上里面就是一个if else 判断,来分别处理select与normal两种状态。

1 当是select状态(复位)时,点击按钮, resetVariables()用来重置所有的变量。余下就用来改变按钮的状态,将按钮设置成不可点击,以及取消select状态。

2 当按钮是normal状态(计次)时,点击之后,这时会重新计算一段时间,所以将intervals 重置为0,并且调用模型中的方法beginingNewTime(),在前面有说到,调用beginNewTime方法,会在模型中的数组首位添加一个新的元素。这样就保证了模型中的数据也在实时更新。

3 无论是点击复位还是计次,最后调用tableviwtableView.reloadData()刷新数据。

    @IBAction func reset(_ sender: WSButton) {
        if sender.isSelected { // 复位
            
            resetVariables()
            
            sender.isEnabled = false
            sender.isSelected = false
        } else { // 计次
            intervals = 0
            data.beginingNewTime()
        }
        
        // 最后刷新显示的数据
        tableView.reloadData()
    }

这里给出resetVariables()方法,比较简单。

    func resetVariables(){
        totalTime = 0
        intervals = 0
        data.reset()
    }

第二个是启动/暂停按钮,我是将title设为启动作为normal状态,title设为停止时作为selected状态。

        startButton.setTitle("启动", 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);

我会着重讲一下这个按钮的点击事件,这里面处理的逻辑或许稍微有些复杂,请保持耐心,我会讲述的细致一些。

这里面可以分成4部分,首行代码加另外3个if判断。

1 首行代码是状态切换。当是select状态时,状态取反,取消select状态。当不是select状态时,状态取反,变成select状态。

2 当点击的是停止时(isSelected = true),状态取反后变成启动(isSelect = flase),那么此时切换计次/复位按钮应该为复位,设置resetButton.isSelected = true,变成复位的状态。然后停止掉定时器,然后再return掉,结束这个方法。

第二步处理了点击停止。3~4都是要处理当点击启动时该做哪些操作。

3.使用模型中的isReset判断当前是否是复位了,如果是,当点击启动之后,就要调用data.beginingNewTime() 重新开启一个计次,并且左边的复位/计次按钮 从不可点击变成可点击计次的状态。如果不是复位,那么当点击启动后,左边按钮要切换成可复位的状态。

4.第四步用来开启定时器,并且在定时器的刷新回调里面更新数据。注意这里调用了模型中的timing(time:)方法,传入intervals,实时更新模型。

    @IBAction func start(_ sender: WSButton) {
    
        // 1
        sender.isSelected = !sender.isSelected
        
        // 2
        if !sender.isSelected { // 点击停止
            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了,比较常规的操作,这里就不做过多介绍了。值得注意的是,这里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,里面有一个方法recordTime(with data: indexPath:),传入模型以及indexPath,indexPath用来取模型数组中的数据。另外根据模型中的maxTimeIndexminTimeIndex来确定是哪个cell,并对应的修改cell上文本的颜色。

代码如下:

    public func recordTime( with data:stopWatchData, _ indexPath:IndexPath){
        
        textLabel?.textColor = UIColor.white
        detailTextLabel?.textColor = UIColor.white
        
        textLabel?.text = "计次\(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
        }
    }

自此功能基本上就实现完成了,如果能耐心看到这里,不妨再往下看看,接下来要说的是项目中的一些细节,主要有三个。

1, 要设置定时器的模式为commonmode,如果不设置的话,当滑动tableview时,会停止计时。

2,我们的按钮是一个正方形,如下图。我们设置cornerRadius后才变成一个圆形,手机屏幕上显示的是如下图的红色区域,但如果点击圆形周围的蓝色部分,还是可以响应事件的。

image

那么如何才能让这一部分蓝色区域不再响应事件呢?

比较简单,这里可以自定义UIBUtton,并且重写是否响应事件的方法。

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 第三个问题时,顶部的label更新数据时会产生抖动。

image

而苹果手机上数字是很稳定,不会抖动的。

image

这个问题该如何解决呢?老实说,我也思考了很久,我尝试过使用NSMutableAttributeString来加大数字之间的间隔,发现并没有效果。后来才发现,需要设置其他的字体才不会抖动,使用系统默认的字体是会抖动的。我在项目中使用的是Helvetica Neue。

我的讲述结束了。

如果还有什么疑惑,欢迎在评论区提问,也可以在github上提issue,当然我更加欢迎加我个人微信:WSAlonely。希望你能帮助我发现实现过程中的问题:)

你可能感兴趣的:(抽丝剥茧实现苹果手机中的秒表功能)