본문 바로가기

iOS/기타

ios 플링 애니메이션 만들기 - 2

앞서 작성한 코드에 다음과 같은 문제가 있었는데요, 이 문제들을 모두 수정해 보도록 하겠습니다.

1. 살짝 플링을 해도 매우 빠르게 스크롤이 됨

   - UIPanGestureRecognizer가 계산해 주는 velocity가 굉장히 큰 값으로 전달되기 때문에 스케일링 처리가 필요합니다. 테스트해 보니 0.03배로 값을 축소하는 것이 좀 자연스럽게 이동하는 것 같습니다.

2. 스크롤이 멈추었는데도 targetPos 로그가 계속해서 출력이 됨 

   - 지수함수 특성상 결과값이 0으로 수렴하기는 하지만 절대 0이 되지는 않기 때문에, 속도가 충분히 줄어들면 무의미한 화면 갱신을 방지하기 위해 강제로 멈추는 방어 코드가 필요합니다.

1번에서 계산한 속도값이 0.1보다 작아지면 유의미한 위치변화가 없는 것을 확인하였으므로, 0.1보다 속도가 작아지게 되면 갱신을 멈추도록 하면 될 것 같습니다.

3. 스크롤 도중에 반대방향으로 플링을 하면 스크롤이 튀는 문제 발생

   - 터치가 되는 순간 이전에 동작하던 CADisplayLink를 강제로 멈추도록 해야 합니다.

 

다음 시간에는 위 문제점들을 보완하는 코드를 추가해 보도록 하겠습니다.

 

class BallListView: UIView {

	...
    
    private let scaleFactor: CGFloat = 0.03 // UIPanGestureRecognizer가 계산해준 velocity를 스케일링하기 위한 값

    ...
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        // touch down시 기존 플링처리 취소
        displayLink?.invalidate()
    }

    @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
            // UIPanGestureRecognizer가 계산해준 velocity 스케일링
            self.velocity = CGPoint(x: velocity.x * scaleFactor, y: velocity.y * scaleFactor)

            displayLink?.invalidate()
            displayLink = CADisplayLink(target: self, selector: #selector(tick))
            displayLink?.add(to: .main, forMode: .default)
        default:
            break
        }
    }
    
	...

    // 디스플레이 주사율에 따라 호출되는 콜백함수
    @objc private func tick() {
        // 감속계수에 따른 속도 감소
        velocity.x *= deceleration
        velocity.y *= deceleration

        // 위치 갱신
        targetPos.x += velocity.x
        targetPos.y += velocity.y

        // 속도가 너무 느려지면 애니메이션을 종료
        if (abs(velocity.x) < 0.1 && abs(velocity.y) < 0.1) {
            displayLink?.invalidate()
            return
        }

        print("targetPos: \(targetPos)")
        // 콜백 호출
        updateLocation(targetPos)
    }

	...
}

 

여기까지 보완한 결과 다음과 같이 자연스러운 플링 애니메이션을 확인할 수 있었습니다.

 

자, 이제는 플링애니메이션 처리 부분을 재활용 할 수 있도록 별도의 클래스로 분리해보겠습니다.

import Foundation
import QuartzCore

class FlingScroller {
    private var velocity: CGPoint = .zero
    private var targetPos: CGPoint = .zero
    private var startPos: CGPoint = .zero
    private var endPos: CGPoint = .zero
    private var callback: ((CGPoint, CGPoint) -> Void)?
    private var displayLink: CADisplayLink?
    private let scaleFactor: CGFloat = 0.03
    private let deceleration: CGFloat = 0.95 // 감속계수

    public func cancel() {
        displayLink?.invalidate()
        displayLink = nil
    }

    public func fling(velocity: CGPoint, startPos: CGPoint, endPos: CGPoint, callback: @escaping (CGPoint, CGPoint) -> Void) {
        self.velocity = CGPoint(x: velocity.x * scaleFactor, y: velocity.y * scaleFactor)
        self.targetPos = startPos
        self.startPos = startPos
        self.endPos = endPos
        self.callback = callback

        // 타이머 대신 CADisplayLink를 사용하여 화면 새로고침
        displayLink?.invalidate()
        displayLink = CADisplayLink(target: self, selector: #selector(tick))
        displayLink?.add(to: .main, forMode: .default)
    }

    @objc private func tick() {
        // 속도 감소
        velocity.x *= deceleration
        velocity.y *= deceleration

        // 위치 갱신
        targetPos.x += velocity.x
        targetPos.y += velocity.y

        print("targetPos: \(targetPos)")
        // X 방향
        if ((startPos.x > endPos.x && targetPos.x < endPos.x) || (startPos.x < endPos.x && targetPos.x > endPos.x) || startPos.x == endPos.x) {
            targetPos.x = endPos.x
        }

        // Y 방향
        if ((startPos.y > endPos.y && targetPos.y < endPos.y) || (startPos.y < endPos.y && targetPos.y > endPos.y) || startPos.y == endPos.y) {
            targetPos.y = endPos.y
        }

        // 속도가 너무 느려지면 애니메이션을 종료
        if (abs(velocity.x) < 0.1 && abs(velocity.y) < 0.1) || (targetPos.x == endPos.x && targetPos.y == endPos.y) {
            cancel()
        }

        // 콜백 호출 (위치, 속도)
        callback?(targetPos, CGPoint(x: velocity.x / scaleFactor, y: velocity.y / scaleFactor))
    }
}

 

위와 같이 별도의 클래스로 분리하였는데요, 시작위치와 끝위치를 추가로 전달받아서 해당 범위내에서만 애니메이션이 동작하도록 보완해 보았습니다.

이를 BallListView 클래스에도 적용해 보겠습니다

import UIKit

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 let flingScroller = FlingScroller()
    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)

    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesBegan(touches, with: event)
        // 플링 처리중 터치시 플링 중단
        if touches.first != nil {
            flingScroller.cancel()
        }
    }

    @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:
            flingScroller.fling(velocity: velocity, startPos: touchLocation, endPos: CGPoint(x: velocity.x > 0 ? CGFloat.greatestFiniteMagnitude : -CGFloat.greatestFiniteMagnitude, y: velocity.y > 0 ? CGFloat.greatestFiniteMagnitude : -CGFloat.greatestFiniteMagnitude)) { [weak self] (pos, _) in
                self?.updateLocation(pos)
            }
        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
    }

}

 

위 코드에서 fling()호출 시 endPos를 CGFloat.greatestFiniteMagnitude로 전달하고 있는데요, 이렇게 되면 끝위치가 굉장히 큰 값이기 때문에 자연스럽게 이동이 멈출때까지 애니메이션이 지속되게 됩니다. 하지만 이 샘플에서는 스크롤 가능한 영역이 정해져 있으므로, 끝위치를 계산해 전달해도 무방합니다.

 

지금까지 플링애니메이션을 알아 보았는데요, 막상 끝내고 보니 의외로 복잡하지 않은 코드로 자연스러운 애니메이션이 구현되었습니다. 이를 응용하여 차트 스크롤이나 이런저런 플링 효과를 구현해볼 수 있을 것 같습니다.

 

마지막으로, FlingScroller의 deceleration값을 UIScreen.main.maximumFramesPerSecond을 이용하여 다음과 같이 조정하는 것도 좋을 것 같습니다. 주사율이 다른 기기가 없어 실제 테스트는 해보지 못하였는데요, 적용하는 것이 좋아 보입니다.

private let deceleration: CGFloat = { // 감속 계수
    switch UIScreen.main.maximumFramesPerSecond {
    case 120...:
        return 0.98  // 주사율이 높은 경우 더 부드럽게 감속
    case 60..<120:
        return 0.95  // 일반적인 감속
    default:
        return 0.90  // 주사율이 낮은 경우 더 빠르게 감속
    }
}()
반응형