본문 바로가기

iOS/기타

iOS ValueAnimator 만들기 - 1

이전 글에서 CABasicAnimation을 이용하여 custom property에 애니메이션을 적용하는 법을 알아보았습니다.

2022.07.04 - [iOS/기타] - iOS CABasicAnimation을 이용한 custom property 애니메이션 처리

 

헌데 이를 이용하기 위해서는 CALayer가 반드시 필요한데요, 때에 따라서는 CALayer 없이 custom property 애니메이션 처리가 필요한 경우도 있을 수 있습니다.

그래서, 오늘은 CABasicAnimation이나 UIView.animate를 이용하지 않고 직접 애니메이션 처리를 해 보고자 합니다.

(Android에서 제공하는 ValueAnimator와 유사하게 이용할 수 있는 animator를 만들 예정입니다.)

 

오늘도 프로그래스바 애니메이션을 구현해 볼 건데요, 우선 UIView에서 직접 라인을 그려보도록 하겠습니다.

class ProgressView: UIView {

    override func draw(_ rect: CGRect) {
        UIColor.red.setStroke()
        let path = UIBezierPath()
        path.move(to: CGPoint(x: 0, y: rect.height / 2))
        path.addLine(to: CGPoint(x: rect.width, y: rect.height / 2))
        path.lineWidth = rect.height * 0.01
        path.stroke()
    }

}

위 코드는 다음과 같은 화면을 그리게 되는데요, 이 line을 0부터 rect.width까지 점점 늘려가며 그리면 progress가 진행되는 것처럼 애니메이션 처리를 할 수 있게 됩니다.

 

 

타이머 구성

다음과 같이 ValueAnimator란 클래스를 구성하여, 무한 동작하는 타이머를 구성해 보았습니다.

class ValueAnimator {
    func start() {
        // 60fps로 동작할수 있도록 timeInterval 설정
        Timer.scheduledTimer(timeInterval: 1.0 / 60, target: self, selector: #selector(tick), userInfo: nil, repeats: true)
    }

    @objc
    func tick(_ timer: Timer) {
    }
}

ValueAnimator().start()로 생성된 타이머는 60pfs로 tick()함수를 계속해서 호출해주게 되는데요, 30fps로 동작하게 하고 싶으면 timeInterval을 1.0 / 30으로 설정하면 되겠지요.

 

위 코드는 타이머를 무한동작시키고 있는데요, 보통 애니메이션은 언제 시작하여 얼마간의 시간만큼 동작할 것인지 설정하게 됩니다.

따라서, 이를 위해 ValueAnimator에 delay 및 duration정보를 추가해 보겠습니다.

class ValueAnimator {
    var startDelay: TimeInterval = 0
    var duration: TimeInterval = 0

    func start() {
        Timer.scheduledTimer(timeInterval: 1.0 / 60, target: self, selector: #selector(tick), userInfo: nil, repeats: true)
        startDelay += Date.timeIntervalSinceReferenceDate
    }

    @objc
    func tick(_ timer: Timer) {
        let curTime = Date.timeIntervalSinceReferenceDate
        if curTime < startDelay {
            // delay시간이 지난 후 애니메이션이 동작할수 있도록 함
            return
        }
        
        let elapsedTime = curTime - startDelay
        if elapsedTime >= duration {
            // 경과시간이 duration보다 커지면 애니메이션 종료
            timer.invalidate()
        } else {
            // 경과시간이 duration보다 작으므로 애니메이션 유지
        }
    }
}

위 ValueAnimator의 start()가 호출되는 순간 startDelay에 현재 시간을 더해주고 있습니다.

때문에, tick() 호출 시 현재시간과 startDelay를 비교하여, 애니메이션 요청시 설정한 delay시간만큼 애니메이션 시작을 지연시킬 수 있게 됩니다.

이후 delay시간이 지난 경우 경과시간을 구하여 duration동안만 타이머가 동작할 수 있도록 처리하면 됩니다.

 

value값 애니메이션 처리

이제 fromValue와 toValue를 설정하고 값의 변화를 전달받을 수 있도록 콜백을 등록할수 있도록 추가해 보겠습니다.

class ValueAnimator {
    var startDelay: TimeInterval = 0
    var duration: TimeInterval = 0
    var fromValue: Double = 0
    var toValue: Double = 0

    private var callback: ((Double) -> Void)?

    func start(_ callback: @escaping ((Double) -> Void)) {
        // 60fps로 동작할수 있도록 timeInterval 설정
        Timer.scheduledTimer(timeInterval: 1.0 / 60, target: self, selector: #selector(tick), userInfo: nil, repeats: true)
        self.callback = callback
        startDelay += Date.timeIntervalSinceReferenceDate
    }

    @objc
    func tick(_ timer: Timer) {
        let curTime = Date.timeIntervalSinceReferenceDate
        if curTime < startDelay {
            // delay시간이 지난 후 애니메이션이 동작할수 있도록 함
            return
        }

        let elapsedTime = curTime - startDelay
        var value: Double = 0
        if elapsedTime >= duration {
            // 경과시간이 duration보다 커지면 value를 toValue로 설정하고 타이머 종료
            value = toValue
            timer.invalidate()
        } else {
            let v = elapsedTime / duration // duration대비 경과시간 비율 구하기
            let term = toValue - fromValue // 총 얼만큼의 값이 변경되어야 하는지 계산
            value = fromValue + term * v // fromValue에 경과시간에 따른 값을 더함
        }
        callback?(value)
    }
}

기본 개념은 간단합니다.

duration대비 경과시간 비율을 구하여, toValue가 되기 위해 fromValue에 얼만큼씩 값을 변화시켜 주어야 하는지 계산 후 콜백을 호출해 주면 됩니다.

(duration = 5초, fromValue = 0, toValue = 100)으로 설정했다고 가정하면,

경과시간이 1초인 경우 fromValue에 20을 더하면 되고, 경과시간이 2.5초인 경우 fromValue에 50을 더해주면 되는 것이지요.

 

이렇게 만든 ValueAnimator를 다음과 같이 이용해 볼 수 있습니다.

class ProgressView: UIView {

    private var progress: CGFloat = 0

    override func draw(_ rect: CGRect) {
        UIColor.red.setStroke()
        let path = UIBezierPath()
        path.move(to: CGPoint(x: 0, y: rect.height / 2))
        path.addLine(to: CGPoint(x: rect.width * progress, y: rect.height / 2))
        path.lineWidth = rect.height * 0.01
        path.stroke()
    }

    func trigger() {
        let animator = ValueAnimator()
        animator.duration = 3
        animator.fromValue = 0.0
        animator.toValue = 1.0
        animator.start { [weak self] value in
            self?.progress = value
            self?.setNeedsDisplay() // 값이 변경될 때마다 UIView 갱신 요청을 한다
        }
    }

}

자 이렇게 프로그래스바 애니메이션이 동작하는 것을 확인할 수 있습니다.

 

 

easing 함수

tick()함수내 value를 계산하는 코드를 보면 시간에 따라 value가 일정하게 늘어나도록 계산하고 있는 것을 확인할 수 있습니다.

이렇게 경과시간 대비 값의 변화를 계산해 주는 함수는 easing함수라고 얘기하는데요, 이 함수의 종류는 굉장히 많습니다.

이는 다음 웹페이지에서 확인해 보실 수 있습니다.

https://easings.net/ko

 

Easing Functions Cheat Sheet

Easing functions specify the speed of animation to make the movement more natural. Real objects don’t just move at a constant speed, and do not start and stop in an instant. This page helps you choose the right easing function.

easings.net

 

위 웹페이지에서 몇 가지만 참조하여 다음과 같이 easing함수를 구현해 보았습니다.

(원하는 항목을 선택하면 제일 아래에 "수학 함수"를 확인 할 수 있습니다.)

enum Easing {
    case linear
    case easeOutCubic
    case easeOutBounce
}

class ValueAnimator {
    ......
    var easing = Easing.linear
    
    ......
    @objc
    func tick(_ timer: Timer) {
        ......
        if elapsedTime >= duration {
            // 경과시간이 duration보다 커지면 value를 toValue로 설정하고 타이머 종료
            value = toValue
            timer.invalidate()
        } else {
            let term = toValue - fromValue
            value = fromValue + term * easing(elapsedTime, duration: duration)
        }
        callback?(value)
    }

    private func easing(_ elapsedTime: TimeInterval, duration: TimeInterval) -> 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;
            }
        }
    }
}

 

easing property를 추가하여 애니메이션 요청 시 easing함수를 선택할 수 있도록 하였는데요, 각 easing함수 별 동작이 어떻게 다른지 영상으로 확인해 보시죠.


차례대로 linear, easeOutCubic, easeOutBounce 순서입니다.

  • linear : 경과시간에 따라 일정하게 값이 증가됩니다.
  • easeOutCubic : 처음엔 급하게 증가하다가 나중에는 점점 느리게 증가합니다.
  • easeOutBounce : 공이 바닥에 튀기듯이 값이 변화합니다.

 

다음은 지금까지 구현한 전체 코드입니다.

 

import UIKit

enum Easing {
    case linear
    case easeOutCubic
    case easeOutBounce
}

class ValueAnimator {
    var startDelay: TimeInterval = 0
    var duration: TimeInterval = 0
    var fromValue: Double = 0
    var toValue: Double = 0
    var easing = Easing.easeOutBounce

    private var callback: ((Double) -> Void)?

    func start(_ callback: @escaping ((Double) -> Void)) {
        // 60fps로 동작할수 있도록 timeInterval 설정
        Timer.scheduledTimer(timeInterval: 1.0 / 60, target: self, selector: #selector(tick), userInfo: nil, repeats: true)
        self.callback = callback
        startDelay += Date.timeIntervalSinceReferenceDate
    }

    @objc
    func tick(_ timer: Timer) {
        let curTime = Date.timeIntervalSinceReferenceDate
        if curTime < startDelay {
            // delay시간이 지난 후 애니메이션이 동작할수 있도록 함
            return
        }

        let elapsedTime = curTime - startDelay
        var value: Double = 0
        if elapsedTime >= duration {
            // 경과시간이 duration보다 커지면 value를 toValue로 설정하고 타이머 종료
            value = toValue
            timer.invalidate()
        } else {
            let term = toValue - fromValue
            value = fromValue + term * easing(elapsedTime, duration: duration)
        }
        callback?(value)
    }

    private func easing(_ elapsedTime: TimeInterval, duration: TimeInterval) -> 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;
            }
        }
    }
}

class ProgressView: UIView {

    private var progress: CGFloat = 0

    override func draw(_ rect: CGRect) {
        UIColor.red.setStroke()
        let path = UIBezierPath()
        path.move(to: CGPoint(x: 0, y: rect.height / 2))
        path.addLine(to: CGPoint(x: rect.width * progress, y: rect.height / 2))
        path.lineWidth = rect.height * 0.01
        path.stroke()
    }

    func trigger() {
        let animator = ValueAnimator2()
        animator.duration = 3
        animator.fromValue = 0.0
        animator.toValue = 1.0
        animator.start { value in
            self.progress = value
            self.setNeedsDisplay()
        }
    }

}
반응형