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

2023. 7. 5. 21:54UIKit, H.I.G

728x90
 

[iOS] 스톱워치 앱 만들기 (1) - 프로젝트 기본 세팅과 Timer에 대한 이해

오랜만에 써보는 iOS 개발 글이다. 오지 않을 것만 같던 전역도 이제 슬슬 보이고 있고, 마냥 휴가 때 놀기만 하는 것이 도저히 견딜 수(?)만 없어서 거의 말출이라 할 수 있는 이번 14일간의 휴가

mini-min-dev.tistory.com

지난 글에 이어서 이제 본격적으로 스톱워치 기능을 구현해 줄 차례이다.
이번 글에서는 1, 2, 5번의 상태 변화 기능을 구현해보려 한다. Lap 버튼 클릭에 따른 테이블 뷰의 셀 추가 관련 부분은 다음 글에서!

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

1. 우측 Start 버튼을 누르면 시간이 움직인다. 처음 상태에서 왼쪽 Lap 버튼은 눌릴 수 없다.

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

 

이번 글에서 구현해줄 기능! (#1. Start를 누르면 가고, Stop을 누르면 멈추고 / #2. Lap을 누르면 아래 테이블 뷰에 셀이 추가)


우선 두 개의 프로퍼티를 만들어주었다.
하나는 스톱워치로써의 기능을 할 mainStopwathch 상수, 하나는 시계의 상태를 체크해 주기 위한 bool값을 담은 isPlay 변수이다.
가장 마지막은 이번 글에서는 다루지 않을거지만, Reset 기능을 위해서 만들어 둔 Lap 타임 기록을 담아둘 Array 변수이다.

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

 

뷰컨에 기본으로 생성되는 Life Cycle에는 lapResetButton의 클릭이 불가능하도록 만들기 위해, isEnabled(활성화) 여부를 false로 설정했다.
아래 있는 테이블 뷰 코드는 테이블 뷰 셀을 등록하기 위해 사용하는 기본적인 코드이니 패쓰!

    // 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
    }

 

이제 버튼 클릭에 따른 상태를 분기처리해서 본격적으로 코드를 짜보자.

우선 좌측에 있는 Lap과 Reset 버튼이다.
기본 화면은 위 Life Cycle 함수에 지정해 준 것처럼 Lap이 표출되고, 버튼은 눌리지 않는 상태이다. 이때, 우측 Start 버튼을 클릭하면 자동으로 이 버튼은 활성화된 Lap 버튼으로 변경된다. 이 부분에서는 "활성화된 Lap 버튼으로 변경된 이후"의 분기를 처리해 줄 거다.

시간이 멈추어져 있을 때 (즉, 사용자가 Reset으로 나타난 버튼을 눌렀을 때) 코드를 살펴보자.
Reset이니까 시간과 테이블 뷰의 셀들을 초기화시켜야 하고, 이 버튼은 다시 비활성화된 Lap 버튼의 초기 상태로 바뀌어야 한다.

시간이 가고 있을 때 (즉, 사용자가 Lap을 눌렀을 때)는 이번 글에서는 간단하게 테이블 뷰 셀의 데이터를 추가해 주면 된다.
(Lap 타임을 추가하고, 차이를 계산하고, 다음 글에서는 이 부분이 복잡해질 예정..!)

// MARK: - @IBAction Properties
    @IBAction func lapResetTime(_ sender: Any) {
        
        // 시간이 멈추어져 있을 때 (버튼은 Reset으로 클릭 가능하도록 표출) -> 상황은 Reset을 눌렀을 때의 코드임
        if !isPlay {
            resetMainTimer()
            changeButton(lapResetButton, title: "Lap", titleColor: UIColor.lightGray)
            lapResetButton.isEnabled = false
        }
        
        // 시간이 가고 있을 때 (버튼은 Lap으로 클릭 가능하도록 표출) -> 상황은 Lap을 눌렀을 때의 코드임
        else {
            if let timerLabelText = timeLabel.text {
                lapTableviewData.append(timerLabelText)
            }
            stopWatchTableView.reloadData()
        }
    }

 

위에서 사용된 액션 함수들은 따로 익스텐션으로 분리해서 만들어줬다.

버튼의 Title과 TitleColor를 변경시켜 주는 changeButton 함수,
invalidate라는 메서드와 시간을 계산하는 카운터를 0으로 변경시켜주는 resetTimer 함수, 테이블 뷰 초기화를 함께 담당하는 resetMainTimer 함수까지 함께 확인할 수 있다.

// 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()
    }
}

 

우측에 있는 Start와 Pause 버튼도 분기처리를 해주자.

반복되는 부분은 시간이 가냐, 마냐에 따라 isPlay 변수의 상태를 변화시켜 주고, 버튼의 상태도 위에 있는 changeButton 함수를 이용해서 함께 바꿔준다는 것이다.


주목해 볼 부분은 시간을 움직이게 만드는 코드이다.
기본적으로 Timer.scheduledTimer라는 메서드를 이용해서 타이머를 움직일 것이다.
파라미터의 내용을 하나씩 보면, timeInterval은 타이머가 실행되는 간격(초)이다. 나는 소수점 두째 자릿수까지 보여주는 스톱워치를 만들 것이므로 최소 단위 0.01을 기준으로 잡는다. (레퍼런스에서 확인)
target은 타이머가 실행될 때, Selector에서 지정한 메시지를 보낼 대상 개체를 의미한다. 타이머가 invalidate(정지)될 때까지 강력한 참조를 유지한다고 나와있다.
selector는 타이머가 실행될 때, 대상에게 보낼 메시지를 의미한다. 우리는 타이머가 업데이트되어야 한다는 것을 알려줄 거다.
그 외에 사용자 정보를 의미하는 userInfo와 타이머의 반복을 정할 repeats까지 매개변수로 나타나있다. (우리는 Stop 이후에도 계속 작동할 거니 true로 해준다.)

@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)
            RunLoop.current.add(mainStopwatch.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)
        }
    }

 

위에서 사용한 updateMainTimer 함수도 위와 같이 익스텐션에 추가했다.
scheduledTimer의 매개변수로 들어가야 하는 함수이기 때문에 앞에 @objc를 붙여서 정의했다.
시간을 표출 시키는 counter는 타이머의 간격 기준인 0.01을 가중치로 잡아줬고, 그 외 주요 내용은 60을 기준으로 앞으로 값을 넘기고 (분/초 단위로), 소수점 두 자리의 포맷을 취한다고 정의한 정도랄까. 

마찬가지로, Selector 부분의 updateMainTimer 코드는 익스텐션으로 추가했다.

// MARK: - Action Functions
extension StopWatchViewController {
    @objc func updateMainTimer() {
        updateTimer(mainStopwatch, label: timeLabel)
    }
    
    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: - Extension
fileprivate extension Selector {
    static let updateMainTimer = #selector(StopWatchViewController.updateMainTimer)
}

 

여기까지 만들어두면, 위와 같이 기본 기능은 구현완료다!
아래는 여기까지의 풀코드 :)

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

import UIKit

class StopWatchViewController: UIViewController {
    
    // MARK: - Properties
    private let mainStopwatch: Stopwatch = Stopwatch()
    private var isPlay: Bool = false
    private var lapTableviewData: [String] = []
    
    // MARK: - @IBOutlet Properties
    @IBOutlet weak var stopWatchTableView: UITableView!
    @IBOutlet weak var timeLabel: 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()
            changeButton(lapResetButton, title: "Lap", titleColor: UIColor.lightGray)
            lapResetButton.isEnabled = false
        }
        
        // 시간이 가고 있을 때 (버튼은 Lap으로 클릭 가능하도록 표출) -> 상황은 Lap을 눌렀을 때의 코드임
        else {
            if let timerLabelText = timeLabel.text {
                lapTableviewData.append(timerLabelText)
            }
            stopWatchTableView.reloadData()
        }
    }
    
    @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)
            RunLoop.current.add(mainStopwatch.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()
    }
    
    @objc func updateMainTimer() {
        updateTimer(mainStopwatch, label: timeLabel)
    }
    
    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() }
        
        return timeCell
    }
}

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

 

다음 글도 업데이트 완료!

 

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

[iOS] 스톱워치 앱 만들기 (2) - 버튼 클릭에 따른 상태 변화 기능 구현 [iOS] 스톱워치 앱 만들기 (1) - 프로젝트 기본 세팅과 Timer에 대한 이해 오랜만에 써보는 iOS 개발 글이다. 오지 않을 것만 같던

mini-min-dev.tistory.com

728x90