관리 메뉴

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

[iOS - Swift] TimerView 구현 방법 (썸네일 테두리 회전 뷰, progress, CAShapeLayer, UIBezierPath) 본문

UI 컴포넌트 (swift)

[iOS - Swift] TimerView 구현 방법 (썸네일 테두리 회전 뷰, progress, CAShapeLayer, UIBezierPath)

jake-kim 2022. 9. 8. 22:59

TimerView

  • 입력한 초만큼 테두리에 stroke가 칠해지는 뷰

TimerView - 5초 입력
10초 입력 - progress가 더욱 느리게 진행

구현 아이디어

  • UIBezierPath를 이용하면 뷰의 테두리 부분의 위치를 쉽게 구할 수 있는 점
  • CAShapeLayer를 이용하면 테두리의 width값과 fillColor, strokeColor, 거기에다가 CABasicAnimation의 "strokeEnd" 애니메이션도 쉽게 사용이 가능
  • 사용하는쪽에서는 단순히 아래에서 구현할 TimerView를 addSubview하고 start(duration:)하여 사용

ex) TimerView를 사용하는쪽

  //  ViewController.swift
  
  private func addTimerView(on subview: UIView) {
    let timerView = TimerView()
    subview.addSubview(timerView)
    timerView.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
      timerView.leftAnchor.constraint(equalTo: subview.leftAnchor),
      timerView.rightAnchor.constraint(equalTo: subview.rightAnchor),
      timerView.bottomAnchor.constraint(equalTo: subview.bottomAnchor),
      timerView.topAnchor.constraint(equalTo: subview.topAnchor),
    ])
    timerView.start(duration: 20)
  }

(사용하는쪽 ViewController.swift 전체 코드)

class ViewController: UIViewController {
  let imageView = RoundImageView()
  
  override func viewDidLoad() {
    super.viewDidLoad()
    
    self.imageView.image = UIImage(named: "dog")
    self.view.addSubview(self.imageView)
    self.imageView.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
      self.imageView.heightAnchor.constraint(equalToConstant: 300),
      self.imageView.widthAnchor.constraint(equalToConstant: 300),
      self.imageView.centerYAnchor.constraint(equalTo: self.view.centerYAnchor),
      self.imageView.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
    ])
  }
  
  override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    
    self.addTimerView(on: self.imageView)
  }
  
  private func addTimerView(on subview: UIView) {
    let timerView = TimerView()
    subview.addSubview(timerView)
    timerView.translatesAutoresizingMaskIntoConstraints = false
    NSLayoutConstraint.activate([
      timerView.leftAnchor.constraint(equalTo: subview.leftAnchor),
      timerView.rightAnchor.constraint(equalTo: subview.rightAnchor),
      timerView.bottomAnchor.constraint(equalTo: subview.bottomAnchor),
      timerView.topAnchor.constraint(equalTo: subview.topAnchor),
    ])
    timerView.start(duration: 20)
  }
}

final class RoundImageView: UIImageView {
  override func layoutSubviews() {
    super.layoutSubviews()
    
    self.clipsToBounds = true
    self.layer.cornerRadius = self.bounds.height / 2.0
  }
}

TimerView 구현

  • 필요한 상수 선언
import UIKit

final class TimerView: UIView {
  private enum Const {
    static let lineWidth = 10.0
    static let startAngle = CGFloat(-Double.pi / 2)
    static let endAngle = CGFloat(3 * Double.pi / 2)
    static let backgroundStrokeColor = UIColor.green.cgColor
    static let progressStrokeColor = UIColor.gray.cgColor
  }
  
}
  • 필요한 layer 프로퍼티 선언
  //  TimerView.swift
  
  private let backgroundLayer: CAShapeLayer = {
    let layer = CAShapeLayer()
    layer.lineWidth = Const.lineWidth
    layer.strokeEnd = 1
    layer.fillColor = UIColor.clear.cgColor
    layer.strokeColor = Const.backgroundStrokeColor
    return layer
  }()
  private let progressLayer: CAShapeLayer = {
    let layer = CAShapeLayer()
    layer.lineWidth = Const.lineWidth
    layer.strokeEnd = 0
    layer.fillColor = UIColor.clear.cgColor
    layer.strokeColor = Const.progressStrokeColor
    return layer
  }()
  • 뷰의 테두리 위치를 알기 위해서 필요한 UIBezierPath 선언
    • 런타임 시에 동적으로 결정되는 frame값을 알기 위해 computed property로 선언
  //  TimerView.swift
  
  private var circularPath: UIBezierPath {
    UIBezierPath(
      arcCenter: CGPoint(x: self.frame.size.width / 2, y: self.frame.size.height / 2),
      radius: self.frame.size.width / 2,
      startAngle: Const.startAngle,
      endAngle: Const.endAngle,
      clockwise: true
    )
  }
  • 외부에서 색상과 lineWidth를 결정할 수 있도록 인터페이스용도인 프로퍼티 추가
    • 해당 프로퍼티들은 독립적으로 상태를 가지고 있지 않고 단순히 값을 넘겨주거나 set용도이므로 computed property로 선언
  var backgroundLayerColor: CGColor? {
    get { self.backgroundLayer.strokeColor }
    set { self.backgroundLayer.strokeColor = newValue }
  }
  var progressLayerColor: CGColor? {
    get { self.progressLayer.strokeColor }
    set { self.progressLayer.strokeColor = newValue }
  }
  var lineWidth: CGFloat {
    get { self.backgroundLayer.lineWidth }
    set { self.backgroundLayer.lineWidth = newValue }
  }
  • init에서 layer들을 addSublayer
  required init() {
    super.init(frame: .zero)
    
    self.backgroundColor = .clear
    self.backgroundLayer.path = self.circularPath.cgPath
    self.progressLayer.path = self.circularPath.cgPath
    
    self.layer.addSublayer(self.backgroundLayer)
    self.layer.addSublayer(self.progressLayer)
  }
  • 핵심 - 뷰의 레이아웃이 변경될때마다 부르는 layoutSublayers(of:)를 override하여 여기서 circularPath를 호출하여 frame이 결정될때 path정보를 가져와서, layer에 입력
  override func layoutSublayers(of layer: CALayer) {
    super.layoutSublayers(of: layer)
    self.backgroundLayer.path = self.circularPath.cgPath
    self.progressLayer.path = self.circularPath.cgPath
  }
  • 코드 베이스로 구현할것이므로 coder 초기화는 fatalError 
  @available(*, unavailable)
  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
  • 입력된 시간만큼 애니메이션이 동작하도록, start(duration:) 메소드 정의
  func start(duration: TimeInterval) {
    self.progressLayer.removeAnimation(forKey: "progress")
    let circularProgressAnimation = CABasicAnimation(keyPath: "strokeEnd")
    circularProgressAnimation.duration = duration
    circularProgressAnimation.toValue = 1.0
    circularProgressAnimation.fillMode = .forwards
    circularProgressAnimation.isRemovedOnCompletion = false
    self.progressLayer.add(circularProgressAnimation, forKey: "progress")
  }

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

Comments