구현된 미리보기는 다음과 같습니다.

타이머 뷰 미리보기 2.gif

구현해야 했던 원형 타이머는 시간이 경과함에 따라 원의 테두리를 따라 움직이는 애니메이션을 구현해야 했습니다.

그 과정에서 어려웠던 두 가지 구현은 다음과 같습니다.

  1. 타이머 시간에 따른 뷰의 상태 변경
  2. 타이머의 멈춤 및 재개 기능

타이머 시간에 따른 뷰의 상태 변경

열거형을 활용하여 각 시간에 따른 케이스를 정의하고, 이를 통해 상태 값을 할당하여 UI에 실시간으로 반영하도록 설정. Rxactive를 확장하여 바인딩될 변수를 생성하고, 이를 컴포넌트의 프로퍼티에 연결. 또한, UI를 업데이트하는 데에는 Driver를 사용하여 문제를 해결했습니다.

반응형 구현은 TimeState 열거형을 만들고 케이스에 따라 associated type으로 시간을 파라미터로 입력 받아서 각 케이스 별로 값을 리턴

코드는 다음과 같음.

enum TimeState {
  // 총 8개의 구간
  case initial(value: Double) // 7~8
  case five(value: Double) // 6~7
  case four(value: Double) // 5~6
  case three(value: Double) // 4~5
  case two(value: Double) // 3~4
  case one(value: Double) // 2~3
  case zero(value: Double) // 1~2
  case over(value: Double) // 0~1
  
  init(rawValue: Double) {
    switch rawValue {
    case 7.0..<8.0:
      self = .initial(value: rawValue)
    case 6.0..<7.0:
      self = .five(value: rawValue)
    case 5.0..<6.0:
      self = .four(value: rawValue)
    case 4.0..<5.0:
      self = .three(value: rawValue)
    case 3.0..<4.0:
      self = .two(value: rawValue)
    case 2.0..<3.0:
      self = .one(value: rawValue)
    case 1.0..<2.0:
      self = .zero(value: rawValue)
    default:
      self = .over(value: rawValue)
    }
  }
  
  var color: DSKitColors {
    switch self {
    case .zero, .five:
      return DSKitAsset.Color.primary500
    case .four:
      return DSKitAsset.Color.thtOrange100
    case .three:
      return DSKitAsset.Color.thtOrange200
    case .two:
      return DSKitAsset.Color.thtOrange300
    case .one:
      return DSKitAsset.Color.thtRed
    default:
      return DSKitAsset.Color.neutral300
    }
  }

  var getText: String {
    switch self {
    case .initial, .over:
      return String("-")
    case .five(let value), .four(let value), .three(let value), .two(let value), .one(let value), .zero(let value):
      return String(Int(value) - 1)
    }
  }
  
  var getProgress: Double {
    switch self {
    case .initial:
      return 1
    case .five(let value), .four(let value), .three(let value), .two(let value), .one(let value), .zero(let value), .over(let value):
      return (value - 2) / 5
    }
  }
}

ViewModel에서 각 셀에서 타이머가 시작되는 timerActiveTrigger를 input으로 받아서 각 케이스 별로 지정된 상태값을 갖는 TimeState를 output으로 전달

//  FallinguserCollectionViewCellModel.swift
final class FallinguserCollectionViewCellModel: ViewModelType {
  
	{ ... }
  
  var disposeBag: DisposeBag = DisposeBag()
  
  struct Input {
    **let timerActiveTrigger: Driver<Bool> // 시간 멈춤, 재개를 위한 Bool 값**
  }
  
  struct Output {
    **let timeState: Driver<TimeState>**
  }
  
  func transform(input: Input) -> Output {
    var currentTime: Double = 8.0
    var startTime: Double = 8.0
    
    let timerActiveTrigger = input.timerActiveTrigger
      .asObservable()
    
    let timer = timerActiveTrigger
      .flatMapLatest { value in
        if !value {
          startTime = currentTime
          return Driver.just(currentTime)
        } else {
          return Observable<Int>.interval(.milliseconds(10),
                                          scheduler: MainScheduler.instance)
          .take(Int(startTime * 100) + 1) // 시간의 총 개수
          .map { value in
            let time = round((startTime * 100) - Double(value)) / 100
            currentTime = time
            return time
          }
          .asDriver(onErrorDriveWith: Driver<Double>.empty())
        }
      }.asDriver(onErrorJustReturn: 8.0)
    
    **let timeState = timer.map { TimeState(rawValue: $0) }**
    let timeZero = timer.filter { $0 == 0 }
    
    return Output(
      **timeState: timeState,**
      timeZero: timeZero,
    )
  }
}

ouputtimeState를 구독해서 Reactive를 확장한 rx.timeState와 바인딩되도록 구현

이렇게 하면 각 컴포넌트의 프로퍼티 값을 일일이 바인딩할 필요없이 하나의 값(timeState)에 따라 한 번에 컴포넌트의 프로퍼티들을 업데이트할 수 있음.

//  FallingUserCollectionViewCell.swift
final class FallingUserCollectionViewCell: TFBaseCollectionViewCell {
  
  var viewModel: FallinguserCollectionViewCellModel!
  
	{ ... }
  
  func bind(_ observer: FallingUserCollectionViewCellObserver,
            index: IndexPath,
            usersCount: Int) {
    
		{ ... }
    
    **output.timeState
      .drive(self.rx.timeState)**
      .disposed(by: self.disposeBag)
****  }
  
  func dotPosition(progress: Double, rect: CGRect) -> CGPoint {
    var progress = progress
    // progress가 -0.05미만 혹은 1이상은 점(dot)을 0초에 위치시키기 위함
    let strokeRange: Range<Double> = -0.05..<0.95
    if !(strokeRange ~= progress) { progress = 0.95 }
    let radius = CGFloat(rect.height / 2 - cardTimeView.timerView.strokeLayer.lineWidth / 2)
    **let angle = 2 * CGFloat.pi * CGFloat(progress) - CGFloat.pi / 2
    let dotX = radius * cos(angle + 0.35)
    let dotY = radius * sin(angle + 0.35)**
    
    let point = CGPoint(x: dotX, y: dotY)
    
    return CGPoint(
      x: rect.midX + point.x,
      y: rect.midY + point.y
    )
  }
}

**extension Reactive where Base: FallingUserCollectionViewCell {
  var timeState: Binder<TimeState>** {
    return Binder(self.base) { (base, timeState) in
      base.cardTimeView.timerView.trackLayer.strokeColor = timeState.fillColor.color.cgColor
      base.cardTimeView.timerView.strokeLayer.strokeColor = timeState.color.color.cgColor
      base.cardTimeView.timerView.dotLayer.strokeColor = timeState.color.color.cgColor
      base.cardTimeView.timerView.dotLayer.fillColor = timeState.color.color.cgColor
      base.cardTimeView.timerView.timerLabel.textColor = timeState.color.color
      base.cardTimeView.progressView.progressBarColor = timeState.color.color
      
      base.cardTimeView.timerView.dotLayer.isHidden = timeState.isDotHidden
      
      base.cardTimeView.timerView.timerLabel.text = timeState.getText
      
      base.cardTimeView.progressView.progress = CGFloat(timeState.getProgress)
      
      // TimerView Animation은 소수점 둘째 자리까지 표시해야 오차가 발생하지 않음
      let strokeEnd = round(CGFloat(timeState.getProgress) * 100) / 100
      base.cardTimeView.timerView.dotLayer.position = base.dotPosition(progress: strokeEnd, rect: base.cardTimeView.timerView.bounds)
      
      base.cardTimeView.timerView.strokeLayer.strokeEnd = strokeEnd
    }
  }
}