android의 경우 Scroller나 FlingAnimation 라이브러리를 이용하여 다음과 같은 플링 애니메이션 처리를 할 수가 있습니다.
헌데, iOS는 관련 라이브러리를 제공하지 않아서 직접 만들어보고자 합니다.
이를 위해 응용할 수학공식은 지수함수입니다. 이 지수함수가 실제 공이나 자동차가 굴러가다가 멈추는 동작을 굉장히 비슷하게 표현한다고 합니다.
각 프레임이 지남에 따라 처음에는 빠르게 속도가 줄어들다가 나중에는 점점 느리게 속도가 줄어들도록해야 하는데요, 이를 간단하게 코드로 표현을 해 보면 다음과 같습니다.
var velocity: CGFloat = 100; // 초기속도 (100px/frame)
let deceleration: CGFloat = 0.95; // 감속계수 (5%씩 감소)
for i in 0..<100 {
velocity *= deceleration;
print("프레임 \(i + 1): 속도 = \(velocity)");
}
프레임 1: 속도 = 95.0
프레임 2: 속도 = 90.25
프레임 3: 속도 = 85.7375
프레임 4: 속도 = 81.45062499999999
프레임 5: 속도 = 77.37809374999999
프레임 6: 속도 = 73.50918906249998
프레임 7: 속도 = 69.83372960937498
프레임 8: 속도 = 66.34204312890623
프레임 9: 속도 = 63.02494097246091
프레임 10: 속도 = 59.87369392383786
......
프레임 99: 속도 = 0.6232136021404209
프레임 100: 속도 = 0.5920529220333998
이제 위 코드에 위치정보를 업데이트하는 코드를 추가하고 그래프로 표현해서 위치변화가 어떻게 나타나는지 확인해 보겠습니다.
var velocity: CGFloat = 100; // 초기속도 (100px/frame)
let deceleration: CGFloat = 0.95; // 감속계수 (5%씩 감소)
var posX: CGFloat = 0;
for i in 0..<100 {
velocity *= deceleration;
posX += velocity;
print("프레임 \(i + 1): 속도 = \(velocity), 위치 = \(posX)");
}
프레임 1: 속도 = 95.0, 위치 = 95.0
프레임 2: 속도 = 90.25, 위치 = 185.25
프레임 3: 속도 = 85.7375, 위치 = 270.9875
프레임 4: 속도 = 81.45062499999999, 위치 = 352.438125
프레임 5: 속도 = 77.37809374999999, 위치 = 429.81621875
프레임 6: 속도 = 73.50918906249998, 위치 = 503.3254078125
프레임 7: 속도 = 69.83372960937498, 위치 = 573.1591374218749
프레임 8: 속도 = 66.34204312890623, 위치 = 639.5011805507811
프레임 9: 속도 = 63.02494097246091, 위치 = 702.526121523242
프레임 10: 속도 = 59.87369392383786, 위치 = 762.3998154470798
......
프레임 99: 속도 = 0.6232136021404209, 위치 = 1888.1589415593305
프레임 100: 속도 = 0.5920529220333998, 위치 = 1888.750994481364
위 그래프를 보면 프레임 초기에는 위치변화가 급격히 나타나다가 점점 작아지는 것을 알 수 있는데요, 이렇게 지수함수로 나타낸 그래프가 실제 마찰력과 공기저항등으로 인해 물체의 속도가 줄어드는 그래프와 상당히 유사하다고 합니다.
자, 이제 위 코드를 적용했을때 위치변화를 눈으로 직접 확인할 수 있도록 테스트 화면을 만들어 다음 항목들을 먼저 확인해 보겠습니다.
- 50개의 원을 좌에서 우측으로 나열하여 표시
- UIPanGestureRecognizer를 이용하여 원 목록을 좌/우 드래그할 수 있도록 구현
- 손을 떼었을때 UIPanGestureRecognizer에서 계산해주는 velocity 확인
class BallListView: UIView {
private let ballCount = 50 // 그려야 할 공의 개수
private var spacingBalls: CGFloat = 0 // 공들 사이의 간격
private var firstBallFrame: CGRect? // 첫번째 공의 위치
private var contentsWidth: CGFloat = 0 // 공들을 그려야 할 전체 영역의 넓이
private var panGestureRecognizer: UIPanGestureRecognizer?
private var prevLocation = CGPoint.zero
required init?(coder: NSCoder) {
super.init(coder: coder)
// 사용자의 드래그 및 플링 제스처를 인식하기 위한 Recognizer 등록
panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureHandler))
self.addGestureRecognizer(panGestureRecognizer!)
}
override func layoutSubviews() {
super.layoutSubviews()
// view의 사이즈에 따라 그릴 공의 크기 설정
let size = bounds.width * 0.2
spacingBalls = size / 4
firstBallFrame = CGRect()
firstBallFrame!.origin = CGPoint(x: bounds.minX, y: (bounds.height - size) / 2)
firstBallFrame!.size = CGSize(width: size, height: size)
contentsWidth = firstBallFrame!.width * CGFloat(ballCount) + spacingBalls * CGFloat(ballCount - 1)
}
@objc func panGestureHandler(recognizer: UIPanGestureRecognizer) {
let touchLocation = recognizer.location(in: self)
let velocity = recognizer.velocity(in: self)
switch recognizer.state {
case .began:
prevLocation = touchLocation
case .changed:
updateLocation(touchLocation)
case .ended, .cancelled:
print("속도 : \(velocity.x)")
default:
break
}
}
override func draw(_ rect: CGRect) {
super.draw(rect)
guard let firstBallFrame = firstBallFrame, let context = UIGraphicsGetCurrentContext() else { return }
// 전체 배경색 그리기
context.setFillColor(UIColor.black.cgColor)
context.fill(rect)
var midX = firstBallFrame.midX
let ballDiameter = firstBallFrame.width
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: ballDiameter / 3), // 원 크기에 따라 폰트 크기 조절
.foregroundColor: UIColor.white
]
for index in 1...ballCount {
// 원의 중심과 프레임 계산
let center = CGPoint(x: midX, y: firstBallFrame.midY)
let ballFrame = CGRect(
x: center.x - ballDiameter / 2,
y: center.y - ballDiameter / 2,
width: ballDiameter,
height: ballDiameter
)
// 원의 영역이 rect와 교차하지 않으면 그리지 않음
if !rect.intersects(ballFrame) {
midX += ballDiameter + spacingBalls
continue
}
// 원 그리기
context.setFillColor(UIColor.orange.cgColor)
context.addArc(center: center, radius: ballDiameter / 2, startAngle: 0, endAngle: .pi * 2, clockwise: true)
context.fillPath()
// 텍스트 그리기
let text = "\(index)"
let textSize = text.size(withAttributes: attributes)
let textRect = CGRect(
x: center.x - textSize.width / 2,
y: center.y - textSize.height / 2,
width: textSize.width,
height: textSize.height
)
text.draw(in: textRect, withAttributes: attributes)
// 다음 원의 중심 좌표 계산
midX += ballDiameter + spacingBalls
}
}
private func updateLocation(_ location: CGPoint) {
if firstBallFrame != nil {
let offsetX = location.x - prevLocation.x
prevLocation = location
if let frame = adjustRectPosition(innerRect: firstBallFrame!.offsetBy(dx: offsetX, dy: 0), outerRect: bounds) {
firstBallFrame = frame
setNeedsDisplay()
}
}
}
// 뷰 영역을 벗어나지 않도록 위치 보정
private func adjustRectPosition(innerRect: CGRect, outerRect: CGRect) -> CGRect? {
var adjustedRect = innerRect
if adjustedRect.minX > outerRect.minX {
adjustedRect.origin.x = outerRect.minX
}
if adjustedRect.minX + contentsWidth < outerRect.maxX {
adjustedRect.origin.x = outerRect.maxX - contentsWidth
}
return adjustedRect
}
}
위 코드를 실행시켜서 좌/우로 드래그를 해 보면 다음과 같이 동작하는 것을 확인할 수 있으며, UIPanGestureRecognizer에서 유저의 플링속도에 따라 velocity를 계산해 주는 것을 알 수 있습니다.
다음은 사용자가 손을 떼었을때 UIPanGestureRecognizer가 전달해 준 속도에 처음에 보여드렸던 지수함수 코드를 적용하여 프레임별 원들의 위치를 업데이트하는 코드를 작성해 보겠습니다.
보통 애니메이션을 나타낼때 60 FPS를 표현하려면 1초에 60개의 프레임을 그리면 되는데요, 이를 위해 Timer를 이용하여 1/60초에 한번씩 위치변경 처리를 하면 됩니다.
헌데 디스플레이의 주사율(화면 갱신 주기)이 정확히 1/60초마다 이루어지는게 아니라고 합니다. 그래서 Timer보다는 디스플레이 주사율에 맞춰 화면 갱신을 해야 좀 더 부드러운 애니메이션을 표시할 수있는데요, 바로 그 디스플레이 주사율에 맞춰 콜백함수를 호출해주는 클래스가 있습니다.
iOS의 QuartzCore모듈에서 제공하는 CADisplayLink란 클래스입니다. 사용법도 Timer와 굉장히 유사하기 때문에 사용에 큰 어려움은 없습니다.
class BallListView: UIView {
...
private var velocity: CGPoint = .zero // 초기 속도
private let deceleration: CGFloat = 0.95 // 감속계수
private var targetPos: CGPoint = .zero // 이동한 위치
private var displayLink: CADisplayLink?
...
@objc func panGestureHandler(recognizer: UIPanGestureRecognizer) {
let touchLocation = recognizer.location(in: self)
let velocity = recognizer.velocity(in: self)
switch recognizer.state {
case .began:
prevLocation = touchLocation
case .changed:
updateLocation(touchLocation)
case .ended, .cancelled:
print("속도 : \(velocity.x)")
targetPos = touchLocation
self.velocity = velocity
displayLink?.invalidate()
displayLink = CADisplayLink(target: self, selector: #selector(tick))
displayLink?.add(to: .main, forMode: .default)
default:
break
}
}
override func draw(_ rect: CGRect) {
...
}
// 디스플레이 주사율에 따라 호출되는 콜백함수
@objc private func tick() {
// 감속계수에 따른 속도 감소
velocity.x *= deceleration
velocity.y *= deceleration
// 위치 갱신
targetPos.x += velocity.x
targetPos.y += velocity.y
print("targetPos: \(targetPos)")
// 콜백 호출
updateLocation(targetPos)
}
...
}
위와 같이 각 프레임마다 위치를 갱신하는 코드를 추가해보았는데요, 실행해 보면 몇가지 문제가 있습니다.
1. 살짝 플링을 해도 매우 빠르게 스크롤이 됨
- UIPanGestureRecognizer가 계산해 주는 velocity가 굉장히 큰 값으로 전달되기 때문에 스케일링 처리가 필요합니다. 테스트해 보니 0.03배로 값을 축소하는 것이 좀 자연스럽게 이동하는 것 같습니다.
2. 스크롤이 멈추었는데도 targetPos 로그가 계속해서 출력이 됨
- 지수함수 특성상 결과값이 0으로 수렴하기는 하지만 절대 0이 되지는 않기 때문에, 속도가 충분히 줄어들면 무의미한 화면 갱신을 방지하기 위해 강제로 멈추는 방어 코드가 필요합니다.
1번에서 계산한 속도값이 0.1보다 작아지면 유의미한 위치변화가 없는 것을 확인하였으므로, 0.1보다 속도가 작아지게 되면 갱신을 멈추도록 하면 될 것 같습니다.
3. 스크롤 도중에 반대방향으로 플링을 하면 스크롤이 튀는 문제 발생
- 터치가 되는 순간 이전에 동작하던 CADisplayLink를 강제로 멈추도록 해야 합니다.
다음 시간에는 위 문제점들을 보완하는 코드를 추가해 보도록 하겠습니다.
'iOS > 기타' 카테고리의 다른 글
ios 플링 애니메이션 만들기 - 2 (0) | 2025.02.04 |
---|---|
iOS ValueAnimator 만들기 - 2 (0) | 2022.07.05 |
iOS ValueAnimator 만들기 - 1 (0) | 2022.07.04 |
iOS CABasicAnimation을 이용한 custom property 애니메이션 처리 (0) | 2022.07.04 |
iOS 14 사진 접근권한 요청하기 (0) | 2022.06.29 |