앞서 ValueAnimator를 만들어보았는데요, 이름 조금 더 보완해 보려 합니다.
2022.07.04 - [iOS/기타] - iOS ValueAnimator 만들기 - 1
기존 ValueAnimator는 각 animator객체마다 타이머가 수행되기 때문에, animator가 많아질수록 타이머도 그만큼 여러 개가 동시에 수행이 되는 문제(?)가 있습니다.
아무래도 타이머가 많아지면 많아질수록 성능 저하가 조금은 발생할 수 있을 것 같습니다. 그래서 타이머는 1개만 생성하여 모든 ValueAnimator가 공유할 수 있도록 변경하고자 합니다.
1개의 타이머에서 여러개의 ValueAnimator에 접근할 수 있어야 하므로, ValueAnimator 목록을 Set으로 관리할 수 있도록 변경해 보겠습니다.
먼저, ValueAnimator를 Set으로 관리할 수 있는 animators property를 추가해 줍니다.(animators는 static으로 선언하여 전역 변수로 만들어 줍니다. - 목록 관리는 한 곳에만 처리되어야 하므로)
class ValueAnimator {
static var animators = Set<ValueAnimator>()
}
이러면 다음과 같이 컴파일 에러가 발생하는데요, Set은 Hashable프로토콜을 준수하는 Element만을 관리해 주기 때문입니다.
그래서 다음과 같이 Hashable 프로토콜을 준수하도록 extension을 추가하였습니다.
extension ValueAnimator: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(startDelay)
hasher.combine(duration)
hasher.combine(fromValue)
hasher.combine(toValue)
hasher.combine(easing)
}
static func == (lhs: ValueAnimator, rhs: ValueAnimator) -> Bool {
lhs === rhs
}
}
자 이제 기존에 구현되어 있던 ValueAnimator의 start()함수를 다음과 같이 변경하고, 애니메이션을 취소할 수 있도록 cancel()함수도 추가하였습니다.
func start(_ callback: @escaping ((Double) -> Void)) {
if ValueAnimator.animators.isEmpty {
// TODO 타이머 동작시키기
}
self.callback = callback
startDelay += Date.timeIntervalSinceReferenceDate
ValueAnimator.animators.insert(self)
}
func cancel() {
ValueAnimator.animators.remove(self)
}
기존에는 start()에서 무조건 타이머를 생성했으나, 이젠 animators가 비어있는 경우에만 타이머를 동작시키고 ValueAnimator객체를 animators에 추가하도록 변경하였습니다.
이제, 타이머는 하나만 생성되고 해당 타이머의 콜백을 수신하는 객체도 하나만 생성하여 처리하도록 Tick이란 클래스를 구성해 보겠습니다.
class Tick {
@objc
func tick(_ timer: Timer) {
let curTime = Date.timeIntervalSinceReferenceDate
ValueAnimator.animators.forEach { animator in
if curTime < animator.startDelay {
return
}
let elapsedTime = curTime - animator.startDelay
var value: Double = 0
if elapsedTime >= animator.duration {
// 애니메이션 완료 처리
value = animator.toValue
ValueAnimator.animators.remove(animator)
if ValueAnimator.animators.isEmpty {
timer.invalidate()
}
} else {
let term = animator.toValue - animator.fromValue
value = animator.fromValue + term * easing(elapsedTime, duration: animator.duration, easing: animator.easing)
}
animator.callback?(value)
}
}
private func easing(_ elapsedTime: TimeInterval, duration: TimeInterval, easing: Easing) -> Double {
var x = elapsedTime / duration
switch easing {
case .linear:
return x
case .easeOutCubic:
return (1 - pow(1 - x, 3))
case .easeOutBounce:
let n1 = 7.5625
let d1 = 2.75
if (x < 1 / d1) {
return n1 * x * x;
} else if (x < 2 / d1) {
x -= 1.5 / d1
return n1 * x * x + 0.75;
} else if (x < 2.5 / d1) {
x -= 2.25 / d1
return n1 * x * x + 0.9375;
} else {
x -= 2.625 / d1
return n1 * x * x + 0.984375;
}
}
}
}
기존에 ValueAnimator내에 구현되어 있던 tick()과 easing()함수를 Tick클래스로 옮겨왔습니다.
tick()함수내에서는 루프를 돌며 animators목록에 존재하는 ValueAnimator들을 처리해 주고 있는데요,
duration이 만료된 ValueAnimator의 경우는 목록에서 제거하고 마지막 ValueAnimator가 제거되는 시점에는 타이머까지 종료하도록 하였습니다.
그럼, 이 Tick클래스를 이용하여 ValueAnimator의 start()함수를 마저 구현해 보도록 하겠습니다.
func start(_ callback: @escaping ((Double) -> Void)) {
if ValueAnimator.animators.isEmpty {
Timer.scheduledTimer(timeInterval: 1.0 / 60, target: Tick(), selector: #selector(Tick.tick), userInfo: nil, repeats: true)
}
self.callback = callback
startDelay += Date.timeIntervalSinceReferenceDate
ValueAnimator.animators.insert(self)
}
animators가 비어있는 경우에만 타이머를 생성하여 Tick클래스의 tick()콜백함수가 호출되도록 처리하였습니다.
이렇게 하면, ValueAnimator객체를 여러개 생성하여 애니메이션 처리를 요청해도 타이머는 1개만 생성되며, 하나의 타이머 내에서 모든 ValueAnimator의 값을 처리하게 됩니다.
그리고, 취소되거나 만료된 ValueAnimator는 animators에서 제거되므로 tick()에서 더 이상 처리를 하지 않게 됩니다.
변경된 전체코드는 다음과 같습니다.
enum Easing {
case linear
case easeOutCubic
case easeOutBounce
}
extension ValueAnimator: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(startDelay)
hasher.combine(duration)
hasher.combine(fromValue)
hasher.combine(toValue)
hasher.combine(easing)
}
static func == (lhs: ValueAnimator, rhs: ValueAnimator) -> Bool {
lhs === rhs
}
}
class ValueAnimator {
var startDelay: TimeInterval = 0
var duration: TimeInterval = 0
var fromValue: Double = 0
var toValue: Double = 0
var easing = Easing.easeOutCubic
fileprivate static var animators = Set<ValueAnimator>()
fileprivate var callback: ((Double) -> Void)?
func start(_ callback: @escaping ((Double) -> Void)) {
if ValueAnimator.animators.isEmpty {
Timer.scheduledTimer(timeInterval: 1.0 / 60, target: Tick(), selector: #selector(Tick.tick), userInfo: nil, repeats: true)
}
self.callback = callback
startDelay += Date.timeIntervalSinceReferenceDate
ValueAnimator.animators.insert(self)
}
func cancel() {
ValueAnimator.animators.remove(self)
}
}
fileprivate class Tick {
@objc
func tick(_ timer: Timer) {
let curTime = Date.timeIntervalSinceReferenceDate
ValueAnimator.animators.forEach { animator in
if curTime < animator.startDelay {
return
}
let elapsedTime = curTime - animator.startDelay
var value: Double = 0
if elapsedTime >= animator.duration {
// 애니메이션 완료 처리
value = animator.toValue
ValueAnimator.animators.remove(animator)
if ValueAnimator.animators.isEmpty {
timer.invalidate()
}
} else {
let term = animator.toValue - animator.fromValue
value = animator.fromValue + term * easing(elapsedTime, duration: animator.duration, easing: animator.easing)
}
animator.callback?(value)
}
}
private func easing(_ elapsedTime: TimeInterval, duration: TimeInterval, easing: Easing) -> Double {
var x = elapsedTime / duration
switch easing {
case .linear:
return x
case .easeOutCubic:
return (1 - pow(1 - x, 3))
case .easeOutBounce:
let n1 = 7.5625
let d1 = 2.75
if (x < 1 / d1) {
return n1 * x * x;
} else if (x < 2 / d1) {
x -= 1.5 / d1
return n1 * x * x + 0.75;
} else if (x < 2.5 / d1) {
x -= 2.25 / d1
return n1 * x * x + 0.9375;
} else {
x -= 2.625 / d1
return n1 * x * x + 0.984375;
}
}
}
}
위 코드는 Timer를 이용하여 화면 갱신 주기를 계산하였는데요, 실제 디스플레이의 갱신주기와 timer의 스케쥴링은 다를수가 있다고 합니다. 따라서 디스플레이의 갱신주기에 맞춰 화면을 갱신하는 것이 좀 더 매끄러운 애니메이션을 처리하는 방법이 되겠습니다.
이를 위해 iOS에서는 CADisplayLink란 클래스를 제공해 주고 있는데요, 이를 이용하여 수정한 코드는 다음과 같습니다.
enum Easing {
case linear
case easeOutCubic
case easeOutBounce
}
extension ValueAnimator: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(startDelay)
hasher.combine(duration)
hasher.combine(fromValue)
hasher.combine(toValue)
hasher.combine(easing)
}
static func == (lhs: ValueAnimator, rhs: ValueAnimator) -> Bool {
lhs === rhs
}
}
class ValueAnimator {
var startDelay: CFTimeInterval = 0
var duration: CFTimeInterval = 0
var fromValue: Double = 0
var toValue: Double = 0
var easing = Easing.easeOutCubic
private static var displayLink: CADisplayLink?
fileprivate static var animators = Set<ValueAnimator>()
fileprivate var callback: ((Double) -> Void)?
func start(_ callback: @escaping ((Double) -> Void)) {
if ValueAnimator.animators.isEmpty {
ValueAnimator.displayLink = CADisplayLink(target: Tick(), selector: #selector(Tick.tick(_:)))
ValueAnimator.displayLink?.add(to: .main, forMode: .default)
}
self.callback = callback
startDelay += CACurrentMediaTime()
ValueAnimator.animators.insert(self)
}
func cancel() {
ValueAnimator.animators.remove(self)
}
}
fileprivate class Tick {
@objc
func tick(_ displayLink: CADisplayLink) {
let curTime = displayLink.timestamp
ValueAnimator.animators.forEach { animator in
if curTime < animator.startDelay {
return
}
let elapsedTime = curTime - animator.startDelay
var value: Double = 0
if elapsedTime >= animator.duration {
// 애니메이션 완료 처리
value = animator.toValue
ValueAnimator.animators.remove(animator)
if ValueAnimator.animators.isEmpty {
displayLink.invalidate()
}
} else {
let term = animator.toValue - animator.fromValue
value = animator.fromValue + term * easing(elapsedTime, duration: animator.duration, easing: animator.easing)
}
animator.callback?(value)
}
}
private func easing(_ elapsedTime: TimeInterval, duration: TimeInterval, easing: Easing) -> Double {
var x = elapsedTime / duration
switch easing {
case .linear:
return x
case .easeOutCubic:
return (1 - pow(1 - x, 3))
case .easeOutBounce:
let n1 = 7.5625
let d1 = 2.75
if (x < 1 / d1) {
return n1 * x * x;
} else if (x < 2 / d1) {
x -= 1.5 / d1
return n1 * x * x + 0.75;
} else if (x < 2.5 / d1) {
x -= 2.25 / d1
return n1 * x * x + 0.9375;
} else {
x -= 2.625 / d1
return n1 * x * x + 0.984375;
}
}
}
}
'iOS > 기타' 카테고리의 다른 글
ios 플링 애니메이션 만들기 - 2 (0) | 2025.02.04 |
---|---|
ios 플링 애니메이션 만들기 - 1 (0) | 2025.02.03 |
iOS ValueAnimator 만들기 - 1 (0) | 2022.07.04 |
iOS CABasicAnimation을 이용한 custom property 애니메이션 처리 (0) | 2022.07.04 |
iOS 14 사진 접근권한 요청하기 (0) | 2022.06.29 |