iyOmSd/Title: Swift

[Swift] CountDown Animation Label 구현

냄수 2021. 2. 11. 19:36
반응형

카운트효과를 줄 수 있는 라벨을 만들어 보고싶었어요

그래서 어떻게 줄수 있는지 찾아봤고

아래와같은 2가지로 구현할 수 있을 것 같아요

 

CATransition

An object that provides an animated transition between a layer's states.

레이어의 상태가 변경되면 애니메이션을 제공해주는 역할이죠

CAAnimation을 상속받고 있구요

이름만 봐도 전환효과를 나타내는 녀석같죠?

 

간단하게 구현하려고

라벨을 생성해주고

버튼을 생성해주고

버튼이 눌리면 아래와같은 효과를 주도록 설정했어요

    func makeCATransitionLabel(_ label: UILabel) {
        let transition = CATransition()
        transition.duration = 1
        transition.timingFunction = .init(name: .easeInEaseOut)
        transition.type = .push
        transition.subtype = .fromTop
        label.layer.add(transition, forKey: CATransitionType.push.rawValue)
    }

duration: 애니메이션 길이

timingFunction: CAMediaTimingFunction타입으로 easeIn과 같은 애니메이션 곡선을 뜻해요

type: push, moveIn, fade, reveal 효과가 있고 선택해서 사용하세용

subtype: 애니메이션의 방향을 정할 수 있어요 fromTop인경우 위로 애니메이션이 진행돼요

 

이건 전체적으로 움직이기 때문에

각각 숫자를 움직이고싶다면 Label을 하나하나 설정해줘서 할 수 있겟죠?

 

위로 올라가거나 아래에서 나타나는 그런 잔상이 싫다면

View를 하나 감싸서 clipsToBounds를 이용해서 영역밖에서 안보이도록 설정할 수 있어요

 

이작업을 Label을 하나씩 설정해주기 복잡하므로 UILabel을 서브클래스해서 만들어볼게요

 


class CountPushLabel: UILabel, CAAnimationDelegate {
    var originCycle: Int!
    var cycle: Int! // 앞보다 늦게끝나기위한 프로퍼티
    var originNum: Int = 0
    var curNum: Int = 0 {
        didSet {
            if curNum > 9 {
                cycle -= 1
                curNum = 0
            }
        }
    }
    var duration: TimeInterval!
    
    func config(num: Int, cycle: Int, duration: TimeInterval = 0.1) {
        self.originCycle = cycle
        self.cycle = cycle
        self.originNum = num
        self.curNum = num
        self.duration = duration
        self.text = "\(num)"
        self.font = .boldSystemFont(ofSize: 20)
    }
    
    func animate() {
        curNum += 1
        self.text = "\(curNum)"
        pushAnimate()
    }
    
    private func pushAnimate() {
        let transition = CATransition()
        transition.duration = duration
        transition.timingFunction = .init(name: .easeInEaseOut)
        transition.type = .push
        transition.subtype = .fromTop
        transition.delegate = self
        self.layer.add(transition, forKey: CATransitionType.push.rawValue)
    }
    
    func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
        if curNum == originNum && cycle < 0 {
            self.layer.removeAllAnimations()
            return
        }
        animate()
    }
    
    func clean() {
        config(num: originNum, cycle: originCycle)
    }
}

curNum을 증가시키면서 새로운 숫자를 넣어주는 방식이고

cycle수만큼 돌리는효과는 앞의 숫자보다 늦게 끝나기위함이구요

transition.delegate를 설정해줘야해요

애니메이션이 끝나면 다시 애니메이션을 작동시키기 위함이에요(계속 돌아가는 효과)

 

저는 이 Label들을 StackView에 담아서 나타냈구요

스택뷰에 clipsToBounds = true효과를 줘서 잔상이 위아래에 나타나지 않도록했어요

 

다시클릭해도 애니메이션이 동작하도록 clean()함수를 구현했구요

 

이것만 쓰면되는거아닌가?

왜아래에 다른방법을 쓰지?

 

애니메이션시간을 짧게할수록 바운스되는 푸시효과가 일어나는 어색한 애니메이션이 나올수도 있어요

이런 애니메이션을 원한다면 그냥 사용해도 상관없지만

 

아래는 위의 방법보다는 살짝 어려운방법이에요

하지만 아래는 자연스러운 애니메이션을 볼 수 있죠

 

CAScrollLayer

A layer that displays scrollable content larger than its own bounds.

The extent of the scrollable area of the CAScrollLayer is defined by the layout of its sublayers.

CAScrollLayer does not provide keyboard or mouse event-handling, nor does it provide visible scrollers.

자신보다 큰 콘텐츠를 넣을 수 있고 서브레이어에 의해서 영역이 정의 된다. 

---- 여기까지는 스크롤 뷰와 같은것 같네요 ----

마지막 줄에

CAScrollLayer는 키보드 또는 마우스 이벤트를 처리할 수 없다! 스크롤러를 제공하지않는다!

 

코드로만 스크롤 기능을 할 수 있는 부분이 다르기때문에 ScrollView / CAScrollLayer 선택을 상황에 맞게 하면될것같네요

 

아무튼 우리는 카운트되는라벨을 직접 스크롤하지않으니 상관없구요

 

코드를 조금 수정해볼 거에요

지금은 Label을 4개를 각각만들엇지만

Label안에서 Label을 4개 만들어주는 그런 작업을 할 거에요

 

이게 더효율적일 것 같네용

 

코드를 보면서 설명하는게 편할거같아요

class CountScrollLabel: UILabel {
    private var scrollLayers: [CAScrollLayer] = []
    private var labels: [UILabel] = []
    private var duration: TimeInterval = 0
    private var originText: String = ""
    
    func config(num: String, duration: TimeInterval) {
        originText = num
        self.duration = duration
        setupLabel(numString: num)
    }
    
    // 가로로 각각 레이블 생성
    private func setupLabel(numString: String) {
        let numArr = numString.map { String($0) }
        var x: CGFloat = 0
        let y: CGFloat = 0
        
        numArr.forEach {
            let label = UILabel()
            label.frame.origin = CGPoint(x: x, y: y)
            label.text = "0"
            label.font = .boldSystemFont(ofSize: 20)
            label.textAlignment = .center
            label.sizeToFit()
            createScrollLayer(label: label, num: Int($0)!)
            x += label.bounds.width
        }
    }
    
    // 각각의 레이블에 대해서 세로로 스크롤레이어 추가
    private func createScrollLayer(label: UILabel, num: Int) {
        let scrollLayer = CAScrollLayer()
        scrollLayer.frame = label.frame
        scrollLayers.append(scrollLayer)
        self.layer.addSublayer(scrollLayer)
        
        makeScrollContent(num: num, scrollLayer: scrollLayer)
    }
    
    // 각각의 레이블의 스크롤레이어에 스크롤될 콘텐츠 레이블추가
    private func makeScrollContent(num: Int, scrollLayer: CAScrollLayer) {
        
        var numSet: [Int] = [0]
        for i in num...num+10 {
            let contentNum = i > 9 ? i % 10 : i
            numSet.append(contentNum)
        }
        
        var height: CGFloat = 0
        for i in numSet {
            let label = UILabel()
            label.text = "\(i)"
            label.font = .boldSystemFont(ofSize: 20)
            label.frame = .init(x: 0, y: height, width: scrollLayer.frame.width, height: scrollLayer.frame.height)
            label.sizeToFit()
            scrollLayer.addSublayer(label.layer)
            labels.append(label) // 저장안하면 해제되서 사라지는 이슈주의
            height = label.frame.maxY
        }
    }
    
    func animate(ascending: Bool) {
        var offset: TimeInterval = 0.0 // 각 자리마다 시간차를 주기위함
        for scrollLayer in scrollLayers {
            let maxY = scrollLayer.sublayers?.last?.frame.origin.y ?? 0
            let animation = CABasicAnimation(keyPath: "sublayerTransform.translation.y")
            animation.duration = duration + offset
            animation.timingFunction = .init(name: .easeOut)
            
            if ascending {
                animation.toValue = 0
                animation.fromValue = maxY
            } else {
                animation.toValue = maxY
                animation.fromValue = 0
            }
            
            scrollLayer.scrollMode = .vertically
            scrollLayer.add(animation, forKey: nil)
            scrollLayer.scroll(to: CGPoint(x: 0, y: maxY))
            
            offset += 0.4
        }
    }
}

이부분은 생성할때 만약 숫자를 "0123" 을 넣어줫다면

numString으로 0123을 받고

 

각 위치에 해당하는 레이블을 생성해주는 로직이구요

 

 

이부분은 위에서 생성한 레이블이 각각 스크롤 될 수 있도록

CAScrollLayer을

추가해주는 로직이에요

 

 

이부분은 각 스크롤레이어에 세로로 스크롤되어질 콘텐츠를 만들어주는 로직이에요

0: 001234567890

1: 012345678901

2: 023456789012

3: 034567890123

 

0부터 시작해서

자기자신 -> +1, +2 +3.... 다시 자기자신으로 돌아오는 알고리즘이구요

이 숫자를 Label에 넣어서 추가해줄거에요

그러면 세로로 추가되어있을거고 스크롤 애니메이션을주면 자연스럽게 되어보이겠죠

 

마지막으로

스크롤 레이어마다 애니메이션을 적용할거고

스크롤 레이어는 0123 이라는 숫자를 넣어줬으면

0, 1, 2, 3 각각에 하나씩 존재해요

0부터 끝까지 혹은 끝부터 0까지

돌아가는 방향을 선택할수 있고

.scroll(to:)를 이용해서 스크롤 효과를 시작하고

offset을 통해서 각 자리마다 다르게 멈출 수 있도록 간격을 조절했어요

반응형