2021. 1. 4. 01:00ㆍiOS
이전 포스팅에서는 간단하게 CABasicAnimation을 이용해서 할 수 있는 애니메이션을 해보았어요..!!
이번에는 이를 활용해서 좀 더 다양한 애니메이션 효과들을 구현해볼려구요! (인터랙션 앱 개발 마스터가 되기 위해..?!)
오늘 구현해 볼 애니메이션은 다들 앱을 사용할 때, 어떤 파일을 다운받거나 할 때 로딩화면을 보여주는 애니메이션 보셨나요..⁉️
뭐 시간을 세는 앱에서도 사용할 수 있을 것 같은데 우선 완성된 화면을 볼게요~!
바로 다음과 같이 시간을 보여주고 그에 맞게 로딩 화면을 나타내는 애니메이션이에요..!!
(참 유용할 것 같죠?)
그렇다면 바로 구현하는 방법에 대해 제가 한 방식으로 설명을 해볼게요.
구현 방법
이 화면에서 구성해야할 UI 요소는 몇가지 일까요??
저는 4가지로 구성을 했어요..!!
- 제일 뒤의 트랙을 나타내는 회색 원형
- 진행상태를 나타내는 파란 원형
- 심장 박동이 뛰는 것 같은 원형
- 남은 시간을 보여주는 라벨
이렇게 4가지랍니다~~
그렇다면 화면을 보았을 때, 애니메이션이 되는 요소 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에서 확인하시면 될 것 같아요 (조금은 다른데 전체적인 틀은 같아요!)
감사합니다 :)
혹시 잘못된 점이나 부족한 점 더 좋은 방법이 있다면 알려주세요!
또한 이해가 안가는 점이 있어도 편하게 물어봐주세요~~
참고 레퍼런스
'iOS' 카테고리의 다른 글
[iOS] Xcode Target이란? (2) | 2021.02.01 |
---|---|
[iOS] CABasicAnimation 활용 (2) - 로딩 애니메이션 (UIBezierPath, CAShapeLayer) (0) | 2021.01.08 |
[iOS] CABasicAnimation란 (0) | 2020.12.31 |
[iOS] CALayer란 (2) | 2020.12.31 |
[iOS] 화면전환 애니메이션 Custom - UIViewControllerAnimatedTransitioing, UIViewControllerTransitioningDelegate (0) | 2020.12.20 |