구현된 미리보기는 다음과 같습니다.
구현해야 했던 원형 타이머는 시간이 경과함에 따라 원의 테두리를 따라 움직이는 애니메이션을 구현해야 했습니다.
그 과정에서 어려웠던 두 가지 구현은 다음과 같습니다.
열거형
을 활용하여 각 시간에 따른 케이스를 정의하고, 이를 통해 상태 값을 할당하여 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,
)
}
}
ouput
의 timeState
를 구독해서 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
}
}
}