[iOS] 스톱워치 앱 만들기 (3) - 테이블 뷰에 랩 타임 추가하기

2023. 7. 6. 12:57UIKit, H.I.G

728x90
 

[iOS] 스톱워치 앱 만들기 (2) - 버튼 클릭에 따른 상태 변화 기능 구현

[iOS] 스톱워치 앱 만들기 (1) - 프로젝트 기본 세팅과 Timer에 대한 이해 오랜만에 써보는 iOS 개발 글이다. 오지 않을 것만 같던 전역도 이제 슬슬 보이고 있고, 마냥 휴가 때 놀기만 하는 것이 도저

mini-min-dev.tistory.com

이번 글에서는 3번과 4번의 절반에 해당하는 "테이블 뷰에 기록이 순서대로 추가되는" 기능 구현을 해보겠다.
기록은 <Lap, 기록, 앞 Lap 과의 차이> 순으로 표출하게 구현할 것이다. 

<내가 생각한 스톱워치 앱 프로세스>

1. 우측 Start 버튼을 누르면 시간이 움직인다. 처음 상태에서 왼쪽 Lap 버튼은 눌릴 수 없다.
2. 시간이 가기 시작하면, Start 버튼은 Reset 버튼으로 바뀌고 Lap 버튼은 누를 수 있도록 바뀐다.
3. Lap 버튼을 누르면 아래 테이블 뷰에 Lap 타임이 순서대로 추가돼야 한다.
4. 테이블 뷰에 추가되어 있는 Lap 타임은 <Lap, 기록, 앞 Lap과의 차이> 순으로 표출되어야 하며, Copy와 Paste가 가능해야 한다.(아이폰에는 없고, 갤럭시에는 있는 기능. 이것이 내가 이번 앱을 개발하기로 한 가장 큰 이유이다.)
5. Reset 버튼을 누르면 시간 라벨과 테이블 뷰는 모두 초기화되어야 하며, 버튼도 (1)과 같은 상태로 돌아간다.

 

이번 글에서 구현할 기능!

 

우선, 테이블 뷰 셀을 하나 만들어줬다. 이름은 StopWatchTableViewCell.
역시 디자인은 마지막에 잡아줄 거고, 이번에는 임시로 라벨 3개를 순서대로 추가하기만 했다.
앞에서부터 Lap 순위를 표출해 줄 lapLabel, 정확한 기록을 표출해 줄 recordLabel, 앞 기록과의 차이를 표출해 줄 diffLabel이다.

 

우리는 앞선 글에서 앞으로 사용할 몇 가지 프로퍼티를 만들어준바가 있다.
여기에 lap 타임을 계산하기 위한 lapStopwathch와 이 스톱워치에서 구할 앞 기록과의 차이를 담기 위한 diffTableViewData Array를 신규로 생성해 주자.
다시 설명하면, mainStopwatch는 정규 시간을 구해주는 Stopwatch 개체, lapTableViewData는 lap 기록을 담아주는 Array다.

    // MARK: - Properties
    private let mainStopwatch: Stopwatch = Stopwatch()
    private let lapStopwatch: Stopwatch = Stopwatch()
    private var isPlay: Bool = false
    private var lapTableviewData: [String] = []
    private var diffTableViewData: [String] = []

 

이미 알고 있겠지만, 테이블 뷰에서 데이터를 표출할 때 사용하는 것은 익스텐션에 있는 UITableViewDataSource 부분이다.

numberOfRowsInSection(테이블 뷰의 로우 개수를 결정하는) 부분은 기록의 개수, 즉 lapTableViewData의 개수만큼 생성하도록 한다.
cell을 반환하는 cellForRowAt 부분에는 위에서 만들어준 stopWatchTableViewCell을 반환시켜 주도록 할 것이다.
(dqueueReusableCell에 대한 설명은 아~~~ 주 예전에 글에서 다룬 적 있다.)

그리고 셀에 있는 3개의 Label들에게 해당하는 값을 할당하는 아주 간단한 방식을 사용했다.

 

[UITableView] 테이블뷰에서 발생했던 Cell reuse 문제와 해결방법

아이폰에서 가장 많이 사용되는 뷰가 무엇이냐 묻는다면 TableView와 CollectionView라고 답할 수 있을 것이다. 이 두 개만 잘 배워놔도 대부분의 뷰를 구현할 수 있을 만큼 테이블뷰와 컬렉션뷰는 iOS

mini-min-dev.tistory.com

// MARK: - TableView DataSource
extension StopWatchViewController: UITableViewDataSource {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return lapTableviewData.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let timeCell = tableView.dequeueReusableCell(withIdentifier: Const.Xib.stopWatchTableViewCell, for: indexPath) as? StopWatchTableViewCell else { return UITableViewCell() }
        
        let lap = lapTableviewData.count - (indexPath as NSIndexPath).row
        
        // Lap 순위
        if let lapLabel = timeCell.lapLabel {
            lapLabel.text = "Lap \(lap)"
        }
        
        // 실제 기록
        if let recordLabel = timeCell.recordLabel {
            recordLabel.text = "\(lapTableviewData[lap-1])"
        }
        
        // 앞선 기록과의 차이
        if let diffLabel = timeCell.diffLabel {
            diffLabel.text = "\(diffTableViewData[lap-1])"
        }
        
        return timeCell
    }
}

 

기록을 추가하는 것은 어렵지 않았는데, 어려움을 느꼈던 부분은 앞선 기록과의 차이를 구하는 부분이었다.
처음에는, 계산 자체에서 마이너스 연산을 하려고 했는데 이게 단순 숫자 형태가 아니라 시간 형태로 되다 보니 계산을 하기가 조금 어려웠다.
그래서 별도의 LapStopWatch를 구현해 기록을 추가하기로 했다.

크게는 앞선 글에 구현한 mainStopwathch와 같고, 여기서는 다른 Lap 타임 부분 위주로 설명하겠다.

사실 뭐 설명이라고 해도 크게 어려울 것 없고, 이 타이머는 Lap이라는 버튼을 눌렀을 때 다시 reset이 되어야 한다는 것이 가장 핵심이다.
그리고, 그 기록은 동일하게 위에서 만든 Array, diffTableViewData에 추가를 해준다.
나머지는 처음 스톱워치를 작동할 때와 같은 코드를 사용했다.

// MARK: - @IBAction Properties
    @IBAction func lapResetTime(_ sender: Any) {
        
        // 시간이 멈추어져 있을 때 (버튼은 Reset으로 클릭 가능하도록 표출) -> 상황은 Reset을 눌렀을 때의 코드임
        if !isPlay {
            resetMainTimer()
            resetLapTimer()
            changeButton(lapResetButton, title: "Lap", titleColor: UIColor.lightGray)
            lapResetButton.isEnabled = false
        }
        
        // 시간이 가고 있을 때 (버튼은 Lap으로 클릭 가능하도록 표출) -> 상황은 Lap을 눌렀을 때의 코드임
        else {
            if let timerLabelText = timeLabel.text {
                lapTableviewData.append(timerLabelText)
            }
            if let diffLabelText = lapTimeLabel.text {
                diffTableViewData.append(diffLabelText)
            }
            
            stopWatchTableView.reloadData()
            resetLapTimer()
            unowned let weakSelf = self
            lapStopwatch.timer = Timer.scheduledTimer(timeInterval: 0.01, target: weakSelf, selector: Selector.updateLapTimer, userInfo: nil, repeats: true)
            RunLoop.current.add(lapStopwatch.timer, forMode: RunLoop.Mode.common)
        }
    }

 

아래는 여기까지의 풀 코드를 첨부하겠다.
또, 테이블 뷰에서 가장 실수를 하기 쉬운 것이 reloadData를 반드시 해줘야 한다는 거다!
새로운 데이터가 들어왔으면, 이를 테이블 뷰에 새로 반영하기 위해서는 반드시 위 코드를 넣어줘야 한다. 어디에 들어있는지 확인해 보자.

//
//  StopWatchViewController.swift
//  WorkOut-Time-iOS
//
//  Created by 민 on 2023/07/04.
//

import UIKit

class StopWatchViewController: UIViewController {
    
    // MARK: - Properties
    private let mainStopwatch: Stopwatch = Stopwatch()
    private let lapStopwatch: Stopwatch = Stopwatch()
    private var isPlay: Bool = false
    private var lapTableviewData: [String] = []
    private var diffTableViewData: [String] = []
    
    // MARK: - @IBOutlet Properties
    @IBOutlet weak var stopWatchTableView: UITableView!
    @IBOutlet weak var timeLabel: UILabel!
    @IBOutlet weak var lapTimeLabel: UILabel!
    @IBOutlet weak var lapResetButton: UIButton!
    @IBOutlet weak var startPauseButton: UIButton!
    
    // MARK: - View Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        
        lapResetButton.isEnabled = false
        
        stopWatchTableView.register(StopWatchTableViewCell.nib(), forCellReuseIdentifier: Const.Xib.stopWatchTableViewCell)
        
        stopWatchTableView.delegate = self
        stopWatchTableView.dataSource = self
    }
    
    // MARK: - @IBAction Properties
    @IBAction func lapResetTime(_ sender: Any) {
        
        // 시간이 멈추어져 있을 때 (버튼은 Reset으로 클릭 가능하도록 표출) -> 상황은 Reset을 눌렀을 때의 코드임
        if !isPlay {
            resetMainTimer()
            resetLapTimer()
            changeButton(lapResetButton, title: "Lap", titleColor: UIColor.lightGray)
            lapResetButton.isEnabled = false
        }
        
        // 시간이 가고 있을 때 (버튼은 Lap으로 클릭 가능하도록 표출) -> 상황은 Lap을 눌렀을 때의 코드임
        else {
            if let timerLabelText = timeLabel.text {
                lapTableviewData.append(timerLabelText)
            }
            if let diffLabelText = lapTimeLabel.text {
                diffTableViewData.append(diffLabelText)
            }
            
            stopWatchTableView.reloadData()
            resetLapTimer()
            unowned let weakSelf = self
            lapStopwatch.timer = Timer.scheduledTimer(timeInterval: 0.01, target: weakSelf, selector: Selector.updateLapTimer, userInfo: nil, repeats: true)
            RunLoop.current.add(lapStopwatch.timer, forMode: RunLoop.Mode.common)
        }
    }
    
    @IBAction func startPauseTime(_ sender: Any) {
        lapResetButton.isEnabled = true
        changeButton(lapResetButton, title: "Lap", titleColor: UIColor.black)
        
        // 시간이 멈추어져 있을 때 (버튼은 Start로 표출) -> 상황은 Start를 눌렀을 때의 코드임
        if !isPlay {
            unowned let weakSelf = self
            
            mainStopwatch.timer = Timer.scheduledTimer(timeInterval: 0.01, target: weakSelf, selector: Selector.updateMainTimer, userInfo: nil, repeats: true)
            lapStopwatch.timer = Timer.scheduledTimer(timeInterval: 0.01, target: weakSelf, selector: Selector.updateLapTimer, userInfo: nil, repeats: true)
            
            RunLoop.current.add(mainStopwatch.timer, forMode: RunLoop.Mode.common)
            RunLoop.current.add(lapStopwatch.timer, forMode: RunLoop.Mode.common)
            
            isPlay = true
            changeButton(startPauseButton, title: "Stop", titleColor: UIColor.red)
        }
        
        // 시간이 가고 있을 때 (버튼은 Stop으로 표출) -> 상황은 Stop을 눌렀을 때의 코드임
        else {
            mainStopwatch.timer.invalidate()
            lapStopwatch.timer.invalidate()
            isPlay = false
            changeButton(startPauseButton, title: "Start", titleColor: UIColor.green)
            changeButton(lapResetButton, title: "Reset", titleColor: UIColor.black)
        }
    }
}

// MARK: - Action Functions
extension StopWatchViewController {
    func changeButton(_ button: UIButton, title: String, titleColor: UIColor) {
        button.setTitle(title, for: UIControl.State())
        button.setTitleColor(titleColor, for: UIControl.State())
    }
    
    func resetTimer(_ stopwatch: Stopwatch, label: UILabel) {
        stopwatch.timer.invalidate()
        stopwatch.counter = 0.0
        label.text = "00:00:00"
    }
    
    func resetMainTimer() {
        resetTimer(mainStopwatch, label: timeLabel)
        lapTableviewData.removeAll()
        stopWatchTableView.reloadData()
    }
    
    func resetLapTimer() {
        resetTimer(lapStopwatch, label: lapTimeLabel)
    }
    
    @objc func updateMainTimer() {
        updateTimer(mainStopwatch, label: timeLabel)
    }
    
    @objc func updateLapTimer() {
        updateTimer(lapStopwatch, label: lapTimeLabel)
    }
    
    func updateTimer(_ stopwatch: Stopwatch, label: UILabel) {
        stopwatch.counter = stopwatch.counter + 0.01
        
        var minutes: String = "\((Int)(stopwatch.counter / 60))"
        if (Int)(stopwatch.counter / 60) < 10 {
            minutes = "0\((Int)(stopwatch.counter / 60))"
        }
        
        var seconds: String = String(format: "%.2f", (stopwatch.counter.truncatingRemainder(dividingBy: 60)))
        if stopwatch.counter.truncatingRemainder(dividingBy: 60) < 10 {
            seconds = "0" + seconds
        }
        
        label.text = minutes + ":" + seconds
    }
}

// MARK: - TableView Delegate
extension StopWatchViewController: UITableViewDelegate {
    
}

// MARK: - TableView DataSource
extension StopWatchViewController: UITableViewDataSource {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return lapTableviewData.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let timeCell = tableView.dequeueReusableCell(withIdentifier: Const.Xib.stopWatchTableViewCell, for: indexPath) as? StopWatchTableViewCell else { return UITableViewCell() }
        
        let lap = lapTableviewData.count - (indexPath as NSIndexPath).row
        
        // Lap 순위
        if let lapLabel = timeCell.lapLabel {
            lapLabel.text = "Lap \(lap)"
        }
        
        // 실제 기록
        if let recordLabel = timeCell.recordLabel {
            recordLabel.text = "\(lapTableviewData[lap-1])"
        }
        
        // 앞선 기록과의 차이
        if let diffLabel = timeCell.diffLabel {
            diffLabel.text = "\(diffTableViewData[lap-1])"
        }
        
        return timeCell
    }
}

// MARK: - Extension
fileprivate extension Selector {
    static let updateMainTimer = #selector(StopWatchViewController.updateMainTimer)
    static let updateLapTimer = #selector(StopWatchViewController.updateLapTimer)
}

 

스톱워치 마지막 글이 궁금하다면??

 

[iOS] 스톱워치 앱 만들기 (4) - UIEditMenuInteraction과 UIPasteboard

[iOS] 스톱워치 앱 만들기 (3) - 테이블 뷰에 랩 타임 추가하기 [iOS] 스톱워치 앱 만들기 (2) - 버튼 클릭에 따른 상태 변화 기능 구현 [iOS] 스톱워치 앱 만들기 (1) - 프로젝트 기본 세팅과 Timer에 대한

mini-min-dev.tistory.com

728x90