[iOS] CABasicAnimation 활용 (1) - Counting Progress (UIBezierPath, CAShapeLayer)

2021. 1. 4. 01:00iOS

반응형

이전 포스팅에서는 간단하게 CABasicAnimation을 이용해서 할 수 있는 애니메이션을 해보았어요..!!

 

[iOS] CABasicAnimation란

요즘 Core Animation에 관심을 가지면서 Core Animation를 공부해보기위해 간단히 사용해보았어요...!! 기존에 UIKit의 요소를 애니메이션을 시키기위해선 간단하게 UIView.animate를 사용하였는데, CALayer를

dongminyoon.tistory.com

이번에는 이를 활용해서 좀 더 다양한 애니메이션 효과들을 구현해볼려구요! (인터랙션 앱 개발 마스터가 되기 위해..?!)

 

오늘 구현해 볼 애니메이션은 다들 앱을 사용할 때, 어떤 파일을 다운받거나 할 때 로딩화면을 보여주는 애니메이션 보셨나요..⁉️

뭐 시간을 세는 앱에서도 사용할 수 있을 것 같은데 우선 완성된 화면을 볼게요~!

바로 다음과 같이 시간을 보여주고 그에 맞게 로딩 화면을 나타내는 애니메이션이에요..!!

(참 유용할 것 같죠?)

 

그렇다면 바로 구현하는 방법에 대해 제가 한 방식으로 설명을 해볼게요.

 

 

구현 방법

이 화면에서 구성해야할 UI 요소는 몇가지 일까요??

 

저는 4가지로 구성을 했어요..!!

 

  1. 제일 뒤의 트랙을 나타내는 회색 원형
  2. 진행상태를 나타내는 파란 원형
  3. 심장 박동이 뛰는 것 같은 원형
  4. 남은 시간을 보여주는 라벨

이렇게 4가지랍니다~~

 

그렇다면 화면을 보았을 때, 애니메이션이 되는 요소 3가지가 보이시나요??

 

  1. 진행상황을 나타내는 파란색 로딩
  2. 남은 시간을 나타내는 라벨
  3. 심장 박동이 뛰는 것 같은 애니메이션

이렇게 크게 3가지를 발견하실 수 있을거에요.

 

지금부터는 이 요소들을 가지고 어떻게 구현했는지 설명드리겠습니다.

 

우선 저 화면을 구성하기 위해 하나의 컨테이너 뷰로 묶어서 사용하기 위해 커스텀 뷰를 만들었어요.

class CountdownProgressBar: UIView {
}

그런 다음 여기서 가장 배경이 되는 회색 원형이 보이시나요?

저 회색 원형을 바탕으로 모든 것이 올라가는 방식이기 때문에, 저 부분을 먼저 구현해볼게요!

 

draw(_:) 메소드를 활용할 거에요!

 

이 메소드는 커스텀으로 UIView을 그림을 그려야할 때 쓸 수 있는 메소드로 여기서 개발자가 원하는 형식으로 내용을 그릴 수 있어요.

class CountdownProgressBar: UIView {
    private lazy var backgroundLayer: CAShapeLayer = {
        let layer = CAShapeLayer()
        layer.lineWidth = 10
        layer.strokeColor = UIColor.lightGray.cgColor
        layer.frame = bounds
        layer.fillColor = UIColor.clear.cgColor
        return layer
    }()
    
    override func draw(_ rect: CGRect) {
        let centerPoint = CGPoint(x: frame.width/2, y: frame.height/2)
        let circularPath = UIBezierPath(arcCenter: centerPoint,
                                        radius: bounds.width/2,
                                        startAngle: -CGFloat.pi/2,
                                        endAngle: 2*CGFloat.pi - CGFloat.pi/2,
                                        clockwise: true)
                                        
        backgroundLayer.path = circularPath.cgPath
        layer.addSublayer(backgroundLayer)
    }
}

우선 backgroundLayer에서 fillColor를 이용해서 원을 채우는 것이 아니기 때문에 투명한 색으로 주었습니다. 

그리고 바깥 선을 이용해서 표시하는 방법이에요!! (나중에 애니메이션도 이 stroke라는 것을 활용할 거에요)

 

그리고 circulaPath라는 변수에 UIBezierPath 클래스를 이용해서 원형을 그려주었어요.

UIBezierPath 클래스는 베지어 곡선이라는 것을 이용해서 개발자가 자유롭게 뷰를 렌더링할 수 있게 기능을 제공해주는 클래스에요.

(다음에 기회가 되면 간단한 UIBezierPath를 이용한 그림그리는 포스팅(?)을 해볼게요)

 

반지름과 센터는 당연히 현재 bounds의 크기의 절반과 중앙을 잡아주었는데, 왜 시작 각도와 끝 각도는 0, 2*.pi가 아니고 다를까요..?

 

바로 0으로 지정했을 때, 시작점이 저희가 생각했을 때 .pi/2 지점이 0이기 때문에 저렇게 지정을 해주었어요.

 

그리고 backgroundLayer의 path를 지금 UIBezierPath를 이용해서 만든 path를 지정해줍니다.

마지막으로 화면에 addSublayer 해주어야 지정이 되겠죠..?!

 

지금까지 문제없이 따라오셨다면 회색 바탕의 원형이 표시되었을 거에요~~

 

 

그럼 이제 다음으로 앞의 진행상황을 나타내는 부분을 구현해볼게요.

 

역시 배경과 같이 CAShapeLayer를 이용할거고 아까 사용했던 UIBezierPath를 또 활용해줄거에요!

class CountdownProgressBar: UIView {
    private lazy var foregroundLayer: CAShapeLayer = {
        let layer = CAShapeLayer()
        layer.lineWidth = 10
        layer.strokeColor = UIColor.blue.cgColor
        layer.fillColor = UIColor.clear.cgColor
        layer.lineCap = .round
        layer.frame = bounds
        return layer
    }()
    
    override func draw(_ rect: CGRect) {
        let centerPoint = CGPoint(x: frame.width/2, y: frame.height/2)
        
        let circularPath = UIBezierPath(arcCenter: centerPoint,
                                        radius: bounds.width/2,
                                        startAngle: -CGFloat.pi/2,
                                        endAngle: 2*CGFloat.pi - CGFloat.pi/2,
                                        clockwise: true)
                                        
        foregroundLayer.path = circularPath.cgPath
        layer.addSublayer(foregroundLayer)
    }
}

지금까진 위의 backgroundLayer를 적용해준 방식과 똑같아요.

 

다만 다른 점은 lineCap이란 것을 따로 설정해주었죠..?

저건 선의 가장 바깥쪽 부분을 둥글게 표현할지 사각으로 표현할지 결정하는 것인데 저는 둥글게 해주었어요~~

(.round, .butt, .rect 3가지 옵션이 있어요 - 궁금하면 여기를 참고해주세요

 

자 이렇게까지하면 파란 진행 상황을 나타내는 화면이 아까 회색 원형을 모두 덮었을건데 아직 애니메이션이 없기 때문에 그런거니 걱정안하셔도 됩니다~

 

 

다음으로 심장 박동같은 효과를 나타내는 화면을 추가해볼게요.

class CountdownProgressBar: UIView {
    private lazy var pulseLayer: CAShapeLayer = {
        let layer = CAShapeLayer()
        layer.lineWidth = 10
        layer.strokeColor = UIColor.lightGray.cgColor
        layer.fillColor = UIColor.clear.cgColor
        layer.frame = bounds
    }()
    
    override func draw(_ rect: CGRect) {
        let centerPoint = CGPoint(x: frame.width/2, y: frame.height/2)
        
        let circularPath = UIBezierPath(arcCenter: centerPoint,
                                        radius: bounds.width/2,
                                        startAngle: -CGFloat.pi/2,
                                        endAngle: 2*CGFloat.pi - CGFloat.pi/2,
                                        clockwise: true)
                                        
        pulseLayer.path = circularPath.cgPath
        layer.addSublayer(pulseLayer)
    }
}

 아까 위의 두 개와 다른게 없죠??

 

여기서 중요한 것이 제가 마지막에 pulseLayer를 설명하였는데 효과를 위한 pulseLayer가 다른 레이어를 덮지 않기 위해서는 pulseLayer를 가장 처음에 addSublayer해주어야 합니다~~

 

마지막으로 남은 시간을 나타내기 위한 Label을 추가해볼게요.

class CountdownProgressBar: UIView {
    private lazy var remainingTimeLabel: UILabe = {
        let label = UILabel(frame: CGRect(x: 0,
                                          y: 0,
                                          width: bounds.width,
                                          height: bounds.height))
                                          
        label.font = UIFont.boldSystemFont(ofSize: 32)
        label.textAlignment = .center
        return label
    }()
    
    override func draw(_ rect: CGRect) {
        addSubview(remainingTimeLabel)
    }
}

여기까지하면 모든 UI 요소들을 추가해주고 만들어주었답니다!

 

그럼 지금까지 코드를 확인해볼게요.

class CountdownProgressBar: UIView {
    private lazy var remainingTimeLabel: UILabel = {
        let remainingLabel = UILabel(frame: CGRect(x: 0,
                                                   y: 0,
                                                   width: bounds.width,
                                                   height: bounds.height))
        remainingLabel.font = UIFont.boldSystemFont(ofSize: 32)
        remainingLabel.textAlignment = .center
        return remainingLabel
    }()

    private lazy var foregroundLayer: CAShapeLayer = {
        let layer = CAShapeLayer()
        layer.lineWidth = 10
        layer.strokeColor = UIColor.blue.cgColor
        layer.fillColor = UIColor.clear.cgColor
        layer.lineCap = .round
        layer.frame = bounds
        layer.strokeEnd = 0
        return layer
    }()

    private lazy var backgroundLayer: CAShapeLayer = {
        let layer = CAShapeLayer()
        layer.lineWidth = 10
        layer.strokeColor = UIColor.lightGray.cgColor
        layer.frame = bounds
        layer.fillColor = UIColor.clear.cgColor
        return layer
    }()
    
    private lazy var pulseLayer: CAShapeLayer = {
        let layer = CAShapeLayer()
        layer.lineWidth = 10
        layer.strokeColor = UIColor.lightGray.cgColor
        layer.fillColor = UIColor.clear.cgColor
        layer.frame = bounds
        return pulseLayer
    }()
    
    override func draw(_ rect: CGRect) {
        let centerPoint = CGPoint(x: frame.width/2, y: frame.height/2)
        
        let circularPath = UIBezierPath(arcCenter: centerPoint,
                                        radius: bounds.width/2,
                                        startAngle: -CGFloat.pi/2,
                                        endAngle: 2*CGFloat.pi - CGFloat.pi/2,
                                        clockwise: true)
                                        
        pulseLayer.path = circularPath.cgPath
        layer.addSublayer(pulseLayer)
        
        backgroundLayer.path = circularPath.cgPath
        layer.addSublayer(backgroundLayer)
        
        foregroundLayer.path = circularPath.cgPath
        layer.addSublayer(foregroundLayer)
        
        addSubview(remainingTimeLabel)
    }
}

 

 

지금부터는 애니메이션을 구현하는 코드입니다!

 

우선 먼저 진행상황을 나타내는 애니메이션을 구현해줄 것인데요.

strokeEnd라는 변수를 활용할거에요~~

(0~1의 값을 가지는데 바깥 라인이 처음부터 끝까지 중 어느정도까지 그려져있는지를 나타내는 변수랍니다.)

 

저희는 strokeEnd 값을 0~1로 변화시키는데 duration 값을 이용해 해당 로딩이 걸리는 시간만큼 진행시킬거에요!

private func animateForegroundLayer() {
    let foregroundAnimation = CABasicAnimation(keyPath: "strokeEnd")
    foregroundAnimation.fromValue = 0
    foregroundAnimation.toValue = 1
    // 아직 duration을 선언안해서 컴파일러 에러가 납니다!
    foregroundAnimation.duration = duration
    foregroundAnimation.fillMode = .forwards
    foregroundAnimation.isRemovedOnCompletion = false
        
    foregroundLayer.add(foregroundAnimation, forKey: "foregroundAnimation")
}

 

다음으로는 박동 효과를 만들어볼게요.

여기서는 두 가지의 CABasicAnimation을 조합해서 만들어줄거에요.

opacity, trasform.scale 두 가지 입니다!

private func animatePulseLayer() {
    let pulseAnimation = CABasicAnimation(keyPath: "transform.scale")
    pulseAnimation.fromValue = 1.0
    pulseAnimation.toValue = 1.2
        
    let pulseOpacityAnimation = CABasicAnimation(keyPath: "opacity")
    pulseOpacityAnimation.fromValue = 0.8
    pulseOpacityAnimation.toValue = 0.0
        
    let animationGroup = CAAnimationGroup()
    animationGroup.animations = [pulseAnimation, pulseOpacityAnimation]
    animationGroup.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
    animationGroup.duration = 1.0
    animationGroup.repeatCount = Float.infinity
        
    pulseLayer.add(animationGroup, forKey: "pulseAnimation")
}

다음과 같이 만들어주고 CAAnimationGroup을 이용해서 하나로 묶어서 애니메이션을 진행할 수 있습니다!

그냥 CABasicAnimation을 같이 진행시키는 것과 동일하지만 묶어서 편하게 사용할 수 있는 거에요~~

 

이제 이 두가지 메소드를 묶어서 beginAnimation()이라는 메소드를 만들고 카운팅이 진행될 때, 애니메이션과 같이 진행하도록 해보겠습니다.

private func beginAnimation() {
    animateForegroundLayer()
    animatePulseLayer()
}

 

이제 거의 다왔습니다!!

 

아직 안한게 있죠? 

바로 시간을 설정해주고 그에 맞게 애니메이션이 진행되는 작업입니다.

 

우선 시간을 재는 Timer 클래스와 남은 시간, 애니메이션 진행 시간을 나타내는 변수들을 선언해볼게요!

class CountdownProgressBar: UIView {
    private var timer = Timer()
    private var remainingTIme = 0.0
    private var duration = 10.0
}

 

이제 이 변수들을 이용해서 카운트다운을 시작하고 그에 맞게 애니메이션만 동작하면 되겠죠..?!

 

지금 만들 메소드는 외부에서도 접근해서 사용할 수 있게 만들어줄거에요! 

원하는 duration을 넣고 해당 시간동안 카운트다운을 할 수 있게 말이죠.

// 카운트 다운을 시작하기 위한 초기 작업
func startCountDown(duration: Double) {
    self.duration = duration
    remainingTime = duration
    remainingTimeLabel.text = "\(reaminingTime)"
    timer.invalidate()
    timer = Timer.scheduledTimer(timeInterval: 1,
                                 target: self,
                                 selector: #selector(handleTimerTick),
                                 userInfo: nil,
                                 repeats: true)
                                 
    beginAnimation()
}

// 1초마다 남은 시간을 확인해서 남은 시간 업데이트
@objc
func handleTimerTick() {
    remainingTime -= 1
        
    if remainingTime < 0 {
        remainingTime = 0
        pulseLayer.removeAllAnimations()
        timer.invalidate()
    }
        
    DispatchQueue.main.async {
        self.remainingTimeLabel.text = "\(self.remainingTime)"
    }
}

위의 코드는 일단 외부에서 카운트 다운을 해당 duration만큼 시작할 수 있게하고 1초마다 현재 남은 라벨을 업데이트하는 코드입니다!

 

만약 0초보다 더 낮으면 현재 타이머를 중지하고 애니메이션을 중지시킵니다.

그리고 종료해주기만하면 됩니다~!

 

지금까지 잘 따라오셨으면 완성된 효과를 확인하실 수 있을거에요!

전체 코드는 Github에서 확인하시면 될 것 같아요 (조금은 다른데 전체적인 틀은 같아요!)

 

 

감사합니다 :)

혹시 잘못된 점이나 부족한 점 더 좋은 방법이 있다면 알려주세요!

또한 이해가 안가는 점이 있어도 편하게 물어봐주세요~~

 

 


참고 레퍼런스

반응형