원의 둘레를 따라 움직이며 셀렉된 버튼을 가리키는 화살표를 구현한 과정을 정리해보겠습니다.
UIBezierPath
를 만듭니다.CAKeyframeAnimation(keyPath: "position")
의 path
로 지정합니다.final class AnimatableArrowLayer: CAShapeLayer {
private let defaultStartAngle = 1.5 * .pi // 기본 위치인 iphone의 angle
private let centerPoint: CGPoint // 경로가 될 원의 중심
private let pathRadius: CGFloat // 경로가 될 원의 반지름
private var startAngle: CGFloat! // 애니메이션 시작점을 저장
override init(layer: Any) {
centerPoint = (layer as! AnimatableArrowLayer).centerPoint
pathRadius = (layer as! AnimatableArrowLayer).pathRadius
super.init(layer: layer)
}
init(center: CGPoint, radius: CGFloat) {
centerPoint = center
pathRadius = radius
super.init()
let arrowImage = UIImage(named: "arrow")!
contents = arrowImage.cgImage
bounds = CGRect(
x: 0.0,
y: 0.0,
width: arrowImage.size.width,
height: arrowImage.size.height)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
...
}
처음엔 커스텀 이니셜라이저안에서 super.init()
만 호출하는 형태의 이니셜라이저만 구현했는데, 그렇게 하면 아래와 같은 크래시가 발생했습니다.
Fatal error: Use of unimplemented initializer 'init(layer:)' for class 'app_show_room.AnimatableArrowLayer'
구현하지 않은 init(layer:)
를 사용했기 때문이라고합니다.
스택 오버플로우에 따르면 init(layer:)
는 Presentation layer에서 사용되기 위한 레이어의 복사본들을 생성하는데 사용됩니다. 서브클래스들은 이 이니셜라이저를 오버라이드하여 인스턴스 변수들을 복사해서 presentation layer에게 전달할 수 있습니다. 그래서 CoreAnimation이 레이어의 복사본들이 필요하다고 판단이 되면 이 이니셜라이저를 호출하게됩니다. (예를 들어 레이어의 strokeColor를 바꾸는 경우)
그래서 서브클래스에 커스텀 변수가 있다면 해당 변수의 값을 이 이니셜라이저의 프로퍼티에 전달해주어야합니다. 여기서 매개변수로 전달되는 레이어는 old layer이기 때문에 서브클래스로 타입캐스팅을 한 후,super.init(layer:)
을 호출하기전에 전달해야합니다.
화살표의 position을 애니메이션 줄 것이기 때문에 CAKeyframeAnimation(keyPath: "position")
을 만듭니다.
func animate(to end: CGFloat) {
let positionAnimation = CAKeyframeAnimation(keyPath: "position")
startAngle
과 endAngle
로 정의된 Arc(호) Path를 만듭니다.
let arcPath = UIBezierPath(
arcCenter: centerPoint,
radius: pathRadius,
startAngle: startAngle,
endAngle: end,
clockwise: clockwise)
애니메이션 방향은 startAngle
과 endAngle
의 크기를 비교하여 정합니다.
그리고 keyframeAnimation의 path
로 지정합니다. 그럼 화살표의 포지션이 path에 따라 변화하며 움직이게됩니다.
duration
(지속시간)과 timingFunction
을 입맛대로 설정합니다.
let clockwise = (startAngle < end) ? true : false
positionAnimation.path = arcPath.cgPath
positionAnimation.timingFunction = .init(name:CAMediaTimingFunctionName.easeInEaseOut)
positionAnimation.duration = 1
여기까지하고 애니메이션을 실행해주면 아래와 같이
이제 위 2가지 버그를 고쳐볼게요!
화살표가 path를 따라 움직인 만큼 rotation되도록 하려면 rotationMode
를 설정합니다.
path를 따라 움질일 때 path의 탄젠트에 맞춰서 객체를 돌릴지 결정하는 프로퍼티입니다.
if clockwise {
positionAnimation.rotationMode = .rotateAuto
} else {
positionAnimation.rotationMode = .rotateAutoReverse
}
시계 반대 방향으로 움직일 땐 .rotateAutoReverse
를 하지 않으면 화살표가 원의 안쪽을 향하더라구요.. path의 방향에 따라 탄젠트 계산이 달리되는 건가요??
애니메이션이 진행될 때는 presentation layer의 값 들로 layer가 셋팅되다가, 종료되면 model layer의 값으로 reset된다고 합니다. 이 때 model layer의 값을 presentation layer의 값으로 변경 시켜주는 프로퍼티가 fillMode
입니다.
여기서 presentaion layer와 model layer는 아래 개념에서 등장하는 내용인데요.
CALayer는 3가지 종류의 Layer object가 존재합니다.
현재 model layer의 position 프로퍼티는 startAngle로 설정되어있기 때문에 처음 시작점으로 보여지게 되는 것이고, presentation layer에서 마지막으로 보여진 angle로 현재 position을 설정합니다. .forwards
는 애니메이션이 완료된 후 최종 상태로 남게됩니다.
positionAnimation.fillMode = .forwards
isRemovedOnCompletion
은 디폴트로 true
이기 때문에 애니메이션이 종료된 후 레이어의 애니메이션에서 제거됩니다. 따라서false
로 설정해줍니다.
positionAnimation.isRemovedOnCompletion = false
그리고 기존에 설정된 기기를 반영한 화살표 위치를 잡아주기 위한 애니메이션인 setPosition(_ start:)
도 위 방법과 같이 구현해줍니다. setPosition(_ start:)
은 이 클래스가 생성되자마자 호출해줍니다.
func setPosition(_ start: CGFloat) {
self.startAngle = start
let initialPositionAnimation = CAKeyframeAnimation(keyPath: "position")
let clockwise = (defaultStartAngle < start) ? true : false
let postionPath = UIBezierPath(
arcCenter: centerPoint,
radius: pathRadius,
startAngle: defaultStartAngle,
endAngle: start,
clockwise: clockwise)
initialPositionAnimation.path = postionPath.cgPath
// 화면이 나타나자마자 실행되므로 원래 그 위치인 것 처럼 보이기위해 아주 짧은 시간동안 진행
initialPositionAnimation.duration = 0.01
initialPositionAnimation.isRemovedOnCompletion = false
initialPositionAnimation.fillMode = .forwards
if clockwise {
initialPositionAnimation.rotationMode = .rotateAuto
} else {
initialPositionAnimation.rotationMode = .rotateAutoReverse
}
add(initialPositionAnimation, forKey: "initialPostion")
}
여기까지 하면 아래와 같은 애니메이션이 완성됩니다.
그런데 애니메이션 종료 후 위치는 남지만, rotation의 상태가 이상하게 동작합니다.
애니메이션이 시작되기 전 기존 애니메이션(초기 위치를 설정해주는 애니메이션)을 삭제해주었더니 정상적으로 작동합니다.
애니메이션을 계속해서 추가해서 .fillMode
가 오작동한 것으로 추측은 되는데...확실하겐 모르겠네요🥲
func animate(to end: CGFloat) {
// 직전에 실행되었던 애니메이션을 제거
self.removeAnimation(forKey: "initialPostion")
let positionAnimation = CAKeyframeAnimation(keyPath: "position")
let clockwise = (startAngle < end) ? true : false
let arcPath = UIBezierPath(
arcCenter: centerPoint,
radius: pathRadius,
startAngle: startAngle,
endAngle: end,
clockwise: clockwise)
positionAnimation.path = arcPath.cgPath
positionAnimation.timingFunction = .init(name: CAMediaTimingFunctionName.easeInEaseOut)
positionAnimation.duration = 1
positionAnimation.fillMode = .forwards
positionAnimation.isRemovedOnCompletion = false
if clockwise {
positionAnimation.rotationMode = .rotateAuto
} else {
positionAnimation.rotationMode = .rotateAutoReverse
}
add(positionAnimation, forKey: "position")
self.startAngle = end
}
우여곡절 끝에 완성!!!✌️
//
// AnimatableArrowLayer.swift
// app-show-room
//
// Created by Moon Yeji on 2022/12/27.
//
import UIKit
final class AnimatableArrowLayer: CAShapeLayer {
private let defaultStartAngle = 1.5 * .pi
private let centerPoint: CGPoint
private let pathRadius: CGFloat
private var startAngle: CGFloat!
override init(layer: Any) {
centerPoint = (layer as! AnimatableArrowLayer).centerPoint
pathRadius = (layer as! AnimatableArrowLayer).pathRadius
super.init(layer: layer)
}
init(center: CGPoint, radius: CGFloat) {
centerPoint = center
pathRadius = radius
super.init()
let arrowImage = UIImage(named: "arrow")!
contents = arrowImage.cgImage
bounds = CGRect(
x: 0.0,
y: 0.0,
width: arrowImage.size.width,
height: arrowImage.size.height)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setPosition(_ start: CGFloat) {
self.startAngle = start
let initialPositionAnimation = CAKeyframeAnimation(keyPath: "position")
let clockwise = (defaultStartAngle < start) ? true : false
let postionPath = UIBezierPath(
arcCenter: centerPoint,
radius: pathRadius,
startAngle: defaultStartAngle,
endAngle: start,
clockwise: clockwise)
initialPositionAnimation.path = postionPath.cgPath
initialPositionAnimation.duration = 0.01
initialPositionAnimation.isRemovedOnCompletion = false
initialPositionAnimation.fillMode = .forwards
if clockwise {
initialPositionAnimation.rotationMode = .rotateAuto
} else {
initialPositionAnimation.rotationMode = .rotateAutoReverse
}
add(initialPositionAnimation, forKey: "initialPostion")
}
func animate(to end: CGFloat) {
self.removeAnimation(forKey: "initialPostion")
let positionAnimation = CAKeyframeAnimation(keyPath: "position")
let clockwise = (startAngle < end) ? true : false
let arcPath = UIBezierPath(
arcCenter: centerPoint,
radius: pathRadius,
startAngle: startAngle,
endAngle: end,
clockwise: clockwise)
positionAnimation.path = arcPath.cgPath
positionAnimation.timingFunction = .init(name: CAMediaTimingFunctionName.easeInEaseOut)
positionAnimation.duration = 0.2
positionAnimation.fillMode = .forwards
positionAnimation.isRemovedOnCompletion = false
if clockwise {
positionAnimation.rotationMode = .rotateAuto
} else {
positionAnimation.rotationMode = .rotateAutoReverse
}
add(positionAnimation, forKey: "moveAroundArc")
self.startAngle = end
}
}