관리 메뉴

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

[iOS - swift] Timer UI 구현 방법 (CAShapeLayer, CABasicAnimation) 본문

iOS 응용 (swift)

[iOS - swift] Timer UI 구현 방법 (CAShapeLayer, CABasicAnimation)

jake-kim 2022. 4. 25. 00:21

Timer UI

구현 아이디어

  • 테두리에 관한 윤곽 레이아웃을 구하기 위해서 UIBezierPath를 사용
    • 이 UIBezierPath의 cgPath값을 밑에 CAShapeLayer에서 사용
  • 테두리에 도는 애니메이션을 적용하기 위해서 2가지의 CAShapeLayer를 사용
    • 회색 선을 타나내는 CAShapeLayer
    • 파란색으로 색상이 채워지는 CAShapeLayer
  • CAShapeLayer 준비
    • 회색 선 layer의 strokeEnd 값은 1.0으로 놓으면 원으로 칠해져 있는 상태
    • 파란색 색상 layer의 strokeEnd 값의 초기값은 0으로 놓고, CABasicAnimation의 "strokeEnd" 애니메이션을 통해서 1초마다 strokeEnd값이 채워지도록 구현

커스텀뷰

  • 필요한 요소 준비
    • 윤곽 레이아웃 path 상태를 가지고 있는 circularPath (UIBezierPath)는 autolayout이 아닌 frame값을 사용하므로, 런타임시에 새로 계산하여 사용하도록 computed property로 선언 (밑에 layoutSublayers에서 호출)
import UIKit

class CircularProgressView: UIView {
  private let timeLabel: UILabel = {
    let label = UILabel()
    label.textColor = .gray
    label.text = "60s"
    label.translatesAutoresizingMaskIntoConstraints = false
    return label
  }()
  
  private let backgroundLayer = CAShapeLayer()
  private let progressLayer = CAShapeLayer()
  private let animationName = "progressAnimation"
  private var timer: Timer?
  private var remainingSeconds: TimeInterval? {
    didSet {
      guard let remainingSeconds = self.remainingSeconds else { return }
      self.timeLabel.text = String(format: "%02ds", Int(remainingSeconds))
    }
  }
  private var circularPath: UIBezierPath {
    UIBezierPath(
      arcCenter: CGPoint(x: self.frame.size.width / 2.0, y: self.frame.size.height / 2.0),
      radius: 80,
      startAngle: CGFloat(-Double.pi / 2),
      endAngle: CGFloat(3 * Double.pi / 2),
      clockwise: true
    )
  }
  
  required init?(coder: NSCoder) {
    fatalError("not implemented")
  }
  override init(frame: CGRect) {
    super.init(frame: frame)
   	// TODO: timerLabel, backgroundLayer, progressLayer 속성 정의
  }
  deinit {
    self.timer?.invalidate()
    self.timer = nil
  }
  
  override func layoutSublayers(of layer: CALayer) {
    super.layoutSublayers(of: layer)
	// TODO: UIBezierPath는 런타임마다 바뀌는 frame값을 참조하여 원의 윤곽 레이아웃을 알아야 하므로,
    // 이곳에 적용
  }
  
  func start() {
  }
  func stop() {
  }
  
}

init에서 layer 속성 정의

  • 핵심은 CAShapeLayer()의 strokeEnd 값
    • strokeEnd: 0 ~1사이의 값 (0이면 안채워져 있고, 1이면 다 채워져 있는 것)
override init(frame: CGRect) {
  super.init(frame: frame)
  
  self.addSubview(self.timeLabel)
  NSLayoutConstraint.activate([
    self.timeLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor),
    self.timeLabel.centerXAnchor.constraint(equalTo: self.centerXAnchor),
  ])
  
  self.backgroundLayer.path = self.circularPath.cgPath
  self.backgroundLayer.fillColor = UIColor.clear.cgColor
  self.backgroundLayer.lineCap = .round
  self.backgroundLayer.lineWidth = 3.0
  self.backgroundLayer.strokeEnd = 1.0
  self.backgroundLayer.strokeColor = UIColor.lightGray.cgColor
  self.layer.addSublayer(self.backgroundLayer)
  
  self.progressLayer.path = self.circularPath.cgPath
  self.progressLayer.fillColor = UIColor.clear.cgColor
  self.progressLayer.lineCap = .round
  self.progressLayer.lineWidth = 3.0
  self.progressLayer.strokeEnd = 0
  self.progressLayer.strokeColor = UIColor.blue.cgColor
  self.layer.addSublayer(self.progressLayer)
}
  • layoutSublayers(of:) 정의
    • circularPath는 autolayout이 아닌 frame값을 이용하므로 런타임시에 변하는 frame값을 적용하기 위해서 layoutSublayer(of:)에서 호출
override func layoutSublayers(of layer: CALayer) {
  super.layoutSublayers(of: layer)
  self.backgroundLayer.path = self.circularPath.cgPath
  self.progressLayer.path = self.circularPath.cgPath
}
  • start() 정의
    • timer부분 - 타이머를 돌려서 1초마다 경과시간을 계산하여 UILabel에 어느정도 지났는지 적용하기 위한 역할
    • animation 부분 - CABasicAnimation의 strokeEnd 애니메이션 적용
func start(duration: TimeInterval) {
  self.remainingSeconds = duration
  
  // timer
  self.timer?.invalidate()
  let startDate = Date()
  self.timer = Timer.scheduledTimer(
    withTimeInterval: 1,
    repeats: true,
    block: { [weak self] _ in
      let remainingSeconds = duration - round(abs(startDate.timeIntervalSinceNow))
      guard remainingSeconds >= 0 else {
        self?.stop()
        return
      }
      self?.remainingSeconds = remainingSeconds
    }
  )
  
  // animation
  self.progressLayer.removeAnimation(forKey: self.animationName)
  let circularProgressAnimation = CABasicAnimation(keyPath: "strokeEnd")
  circularProgressAnimation.duration = duration
  circularProgressAnimation.toValue = 1.0
  circularProgressAnimation.fillMode = .forwards
  circularProgressAnimation.isRemovedOnCompletion = false
  self.progressLayer.add(circularProgressAnimation, forKey: self.animationName)
}
  • stop() 정의
    • 타이머를 종료하고, progressLayer의 animation을 종료
func stop() {
  self.timer?.invalidate()
  self.progressLayer.removeAnimation(forKey: self.animationName)
  self.remainingSeconds = 60
}

* 전체 코드: https://github.com/JK0369/ExTimerUI

*참고

https://cemkazim.medium.com/how-to-create-animated-circular-progress-bar-in-swift-f86c4d22f74b

 

Comments