Notice
Recent Posts
Recent Comments
Link
관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] 3. Timer 구현하기 - CircularProgressBar UI 구현(CAShapeLayer 사용), DispatchSourceTimer와 Timer로 타이머 구현 방법 본문

iOS 응용 (swift)

[iOS - swift] 3. Timer 구현하기 - CircularProgressBar UI 구현(CAShapeLayer 사용), DispatchSourceTimer와 Timer로 타이머 구현 방법

jake-kim 2021. 11. 25. 23:32

1. Timer 구현하기 - UIDatePicker 개념, Timer로 구현 방법

2. Timer 구현하기 - DispatchSourceTimer로 구현 방법 (Background에서도 동작)

3. Timer 구현하기 - CircularProgressBar UI 구현(CAShapeLayer 사용), DispatchSourceTimer와 Timer로 타이머 구현 방법

아이디어

  • 첫 번째 화면에서 UIDatePicker를 통해 시간을 선택하고 확인을 누르면 먼저 DispatchSourceTimer로 만든 모듈이 동작
  • 확인 버튼을 눌렀을때 다음 화면에서 직접 만든 CircularTimerView에 타이머 관련된 정보를 표출

첫 번째 화면 SettinTimerVC는 글을 2. Timer 구현하기 - DispatchSourceTimer로 구현 방법 (Background에서도 동작) 참고

class SettingTimerVC: UIViewController {
    
    private let repeatingSecondsTimer: RepeatingSecondsTimer
    
    private lazy var countDownDatePicker: UIDatePicker = {
        let picker = UIDatePicker()
        picker.datePickerMode = .countDownTimer
        return picker
    }()
    
    private lazy var confirmButton: UIButton = {
        let button = UIButton()
        button.setTitle("확인", for: .normal)
        button.setTitleColor(.systemBlue, for: .normal)
        button.setTitleColor(.blue, for: .highlighted)
        button.addTarget(self, action: #selector(didTapConfirmButton), for: .touchUpInside)
        return button
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupViews()
        addSubviews()
        setupLayout()
    }
    
    private func setupViews() {
        view.backgroundColor = .systemBackground
    }
    
    private func addSubviews() {
        view.addSubview(countDownDatePicker)
        view.addSubview(confirmButton)
    }
    
    private func setupLayout() {
        countDownDatePicker.translatesAutoresizingMaskIntoConstraints = false
        countDownDatePicker.topAnchor.constraint(equalTo: view.topAnchor, constant: 100).isActive = true
        countDownDatePicker.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        
        confirmButton.translatesAutoresizingMaskIntoConstraints = false
        confirmButton.topAnchor.constraint(equalTo: countDownDatePicker.bottomAnchor, constant: 56).isActive = true
        confirmButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
    }
    
    init(repeatingSecondsTimer: RepeatingSecondsTimer) {
        self.repeatingSecondsTimer = repeatingSecondsTimer
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError()
    }
    
    @objc private func didTapConfirmButton() {
        startTimer()
        
        let circularTimerVC = CircularTimerVC(startDate: Date(), countDownDurationSeconds: countDownDatePicker.countDownDuration)
        navigationController?.pushViewController(circularTimerVC, animated: true)
    }
    
    private func startTimer() {
        repeatingSecondsTimer.start(durationSeconds: countDownDatePicker.countDownDuration, repeatingExecution: nil) {
            print("완료")
        }
    }
}

CircularTimerView (CircularProgressBar) UI 구현

CircularTimerView

  • extension 정의
    • extension TimeInterval: TimeInterval값을 String값으로 표현 "00:00"
    • extension Int: degree 단위를 radian 단위로 변경
extension TimeInterval {
    /// %02d: 빈자리를 0으로 채우고, 2자리 정수로 표현
    var time: String {
        return String(format:"%02d:%02d", Int(self/60), Int(ceil(truncatingRemainder(dividingBy: 60))) )
    }
}
extension Int {
    var degreesToRadians: CGFloat {
        return CGFloat(self) * .pi / 180
    }
}
  • 원을 그리기 전에 필요한 instance 선언
    • startDate: 시작 시간
    • leftSeconds: 끝나기까지 남아있는 시간
    • endSeconds: 끝나는 시간
    • delegate: 타이머가 끝난것을 알려주는 인터페이스
protocol CircularTimerViewDelegate: AnyObject {
    func didFinishTimer()
}

struct ProgressColors {
    var trackLayerStrokeColor: CGColor = UIColor.lightGray.cgColor
    var barLayerStrokeColor: CGColor = UIColor.green.cgColor
}

class CircularTimerView: UIView {
    
    private let progressColors: ProgressColors
    private let startDate: Date
    private var leftSeconds: TimeInterval
    private lazy var timer = Timer()
    private lazy var endSeconds = startDate.addingTimeInterval(leftSeconds)
    weak var delegate: CircularTimerViewDelegate?
    
}
  • 원을 표출하기 위해서 UIBezierPath를 사용
    • path에 관한 곡선, 직선을 그릴 수 있는 인스턴스이며 해당 path를 CAShapeLayer에 입력하면 path 모양이 반영
    • 원을 그리는 UIBezierPath();arcCenter:radius:startAngle:endAngle:clockwise:)를 사용하여 원 path모양 생성

private lazy var circularPath: UIBezierPath = {
    return UIBezierPath(arcCenter: CGPoint(x: bounds.midX, y: bounds.midY),
                        radius: 100, // 반지름
                        startAngle: -90.degreesToRadians, // 12시 방향 (0도가 3시방향)
                        endAngle: CGFloat.pi * 2, // 2시 방향
                        clockwise: true)
}()
  • CAShapeLayer의 path 프로퍼티에 위에서 만든 UIBezierPath 인스턴스를 입력하여 원 생성
private lazy var trackLayer: CAShapeLayer = {
    let layer = CAShapeLayer()
    layer.path = circularPath.cgPath
    layer.fillColor = UIColor.clear.cgColor
    layer.strokeColor = progressColors.trackLayerStrokeColor
    layer.lineWidth = 15
    return layer
}()

private lazy var barLayer: CAShapeLayer = {
    let layer = CAShapeLayer()
    layer.path = circularPath.cgPath
    layer.fillColor = UIColor.clear.cgColor
    layer.strokeColor = progressColors.barLayerStrokeColor
    layer.lineWidth = 15
    return layer
}()

private func addSubviews() {
    layer.addSublayer(trackLayer)
    layer.addSublayer(barLayer)
}
  • 시간정보를 표출할 UILabel 정의
private lazy var timeLabel: UILabel = {
    let label = UILabel(frame: CGRect(x: frame.midX - 50,
                                      y: frame.midY - 25,
                                      width: 100,
                                      height: 50))
    label.textAlignment = .center
    label.textColor = .label
    return label
}()
  • barLayer에 track을 따라서 그려질 애니메이션 적용
    • CABasicAnimation 인스턴스를 생성하여 barLayer에 추가
private func animateToBarLayer() {
    let strokeAnimation = CABasicAnimation(keyPath: "strokeEnd")
    strokeAnimation.fromValue = 0
    strokeAnimation.toValue = 1
    strokeAnimation.duration = leftSeconds
    
    barLayer.add(strokeAnimation, forKey: nil)
    timer = Timer.scheduledTimer(timeInterval: 0.1,
                                 target: self,
                                 selector: #selector(updateTime),
                                 userInfo: nil,
                                 repeats: true)
}

@objc private func updateTime() {
    if leftSeconds > 0 {
        leftSeconds = endSeconds.timeIntervalSinceNow
        timeLabel.text = leftSeconds.time
    } else {
        timer.invalidate()
        timeLabel.text = "00:00"
        delegate?.didFinishTimer()
    }
}
  • 전체 CircularTimerView 코드
import Foundation
import UIKit

protocol CircularTimerViewDelegate: AnyObject {
    func didFinishTimer()
}

struct ProgressColors {
    var trackLayerStrokeColor: CGColor = UIColor.lightGray.cgColor
    var barLayerStrokeColor: CGColor = UIColor.green.cgColor
}

class CircularTimerView: UIView {
    
    private let progressColors: ProgressColors
    private let startDate: Date
    private var leftSeconds: TimeInterval
    private lazy var timer = Timer()
    private lazy var endSeconds = startDate.addingTimeInterval(leftSeconds)
    weak var delegate: CircularTimerViewDelegate?
    
    private lazy var circularPath: UIBezierPath = {
        return UIBezierPath(arcCenter: CGPoint(x: bounds.midX, y: bounds.midY),
                            radius: 100, // 반지름
                            startAngle: -90.degreesToRadians, // 12시 방향 (0도가 3시방향)
                            endAngle: CGFloat.pi * 2, // 2시 방향
                            clockwise: true)
    }()
    
    private lazy var trackLayer: CAShapeLayer = {
        let layer = CAShapeLayer()
        layer.path = circularPath.cgPath
        layer.fillColor = UIColor.clear.cgColor
        layer.strokeColor = progressColors.trackLayerStrokeColor
        layer.lineWidth = 15
        return layer
    }()

    private lazy var barLayer: CAShapeLayer = {
        let layer = CAShapeLayer()
        layer.path = circularPath.cgPath
        layer.fillColor = UIColor.clear.cgColor
        layer.strokeColor = progressColors.barLayerStrokeColor
        layer.lineWidth = 15
        return layer
    }()
    
    private lazy var timeLabel: UILabel = {
        let label = UILabel(frame: CGRect(x: frame.midX - 50,
                                          y: frame.midY - 25,
                                          width: 100,
                                          height: 50))
        label.textAlignment = .center
        label.textColor = .label
        return label
    }()
    
    init(progressColors: ProgressColors, duration: TimeInterval, startDate: Date) {
        self.progressColors = progressColors
        self.leftSeconds = duration
        self.startDate = startDate
        super.init(frame: .zero)
        
        addSubviews()
        setupViews()
    }
    
    required init?(coder: NSCoder) {
        fatalError()
    }
    
    private func addSubviews() {
        layer.addSublayer(trackLayer)
        layer.addSublayer(barLayer)
        addSubview(timeLabel)
    }
    
    private func setupViews() {
        animateToBarLayer()
    }
    
    private func animateToBarLayer() {
        let strokeAnimation = CABasicAnimation(keyPath: "strokeEnd")
        strokeAnimation.fromValue = 0
        strokeAnimation.toValue = 1
        strokeAnimation.duration = leftSeconds
        
        barLayer.add(strokeAnimation, forKey: nil)
        timer = Timer.scheduledTimer(timeInterval: 0.1,
                                     target: self,
                                     selector: #selector(updateTime),
                                     userInfo: nil,
                                     repeats: true)
    }
    
    @objc private func updateTime() {
        if leftSeconds > 0 {
            leftSeconds = endSeconds.timeIntervalSinceNow
            timeLabel.text = leftSeconds.time
        } else {
            timer.invalidate()
            timeLabel.text = "00:00"
            delegate?.didFinishTimer()
        }
    }
    
}

CircularTimerView를 사용하는쪽

  • CircularTimerVC 정의
class CircularTimerVC: UIViewController {
    private let countDownDurationSeconds: TimeInterval
    private let startDate: Date
    
    
    init(startDate: Date, countDownDurationSeconds: TimeInterval) {
        self.startDate = startDate
        self.countDownDurationSeconds = countDownDurationSeconds
        
        super.init(nibName: nil, bundle: nil)
    }
}
  • circularTimerView 선언
private lazy var circularTimerView: CircularTimerView = {
    let progressColors = ProgressColors(trackLayerStrokeColor: UIColor.lightGray.cgColor,
                                        barLayerStrokeColor: UIColor.green.cgColor)
    let view = CircularTimerView(progressColors: progressColors,
                                 duration: countDownDurationSeconds,
                                 startDate: startDate)
    return view
}()
  • 전체 CircularTimerVC 코드 
import Foundation
import UIKit

class CircularTimerVC: UIViewController {
    
    private let countDownDurationSeconds: TimeInterval
    private let startDate: Date
    
    private lazy var circularTimerView: CircularTimerView = {
        let progressColors = ProgressColors(trackLayerStrokeColor: UIColor.lightGray.cgColor,
                                            barLayerStrokeColor: UIColor.green.cgColor)
        let view = CircularTimerView(progressColors: progressColors,
                                     duration: countDownDurationSeconds,
                                     startDate: startDate)
        return view
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupViews()
        addSubviews()
        makeConstraints()
    }
    
    init(startDate: Date, countDownDurationSeconds: TimeInterval) {
        self.startDate = startDate
        self.countDownDurationSeconds = countDownDurationSeconds
        
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError()
    }
    
    private func setupViews() {
        view.backgroundColor = .systemBackground
    }
    
    private func addSubviews() {
        view.addSubview(circularTimerView)
    }
    
    private func makeConstraints() {
        circularTimerView.translatesAutoresizingMaskIntoConstraints = false
        circularTimerView.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
        circularTimerView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
    }
}

* 전체 소스 코드: https://github.com/JK0369/ExCircularTimer

 

* 참고

- https://developer.apple.com/documentation/quartzcore/cabasicanimation

- https://developer.apple.com/documentation/quartzcore/cashapelayer

- https://developer.apple.com/documentation/uikit/uibezierpath

Comments