AnimationBackground - ABBoundingView

mynameisjaehoon·2023년 10월 28일
0
post-thumbnail

https://github.com/mynameisjaehoon/AnimationBackground-Swift

AnimationBackground-Swift 라이브러리의 계획했던 마지막 기능인 Bounding View를 구현하는 과정, 어떤고민이 있었는지 적어보도록 하겠습니다. 먼저 Bounding View가 어떤 뷰인지 먼저 보여드리겠습니다.

result3

위 화면이 완성된 화면인데요, 사용자가 이미지를 등록하면 회전하면서 움직이고, 경계면에 도달했을 때 충돌하여 다른 방향으로 나아가는 애니메이션 입니다.

GIF 에도 볼수 있듯이 크게 세가지 동작이 있습니다.

  1. 이미지가 회전한다.
  2. 이미지가 이동한다.
  3. 경계면에 부딪히면 다른 방향으로 이동한다.

구현한 순서이기도 하지만 난이도의 순서이기도 합니다. 마지막 3번을 구현하는 것에 제일 시간을 많이 사용하였습니다. 그럼 하나하나 천천히 살펴보겠습니다.

이미지 회전시키기

이번에 구현하면서 가장쉬웠던 부분입니다. 모든 view에는 CALayer타입의 layer가 존재하고, 이 layer에 CABasicAnimation만 추가시키면 간단하게 구현할수 있습니다. 사실거의 있는 API를 그대로 이용하고, 제가 고민했던 부분은 거의 없는 부분입니다. 간단한 키워드를 제공하는 것만으로 구현할 수 있습니다. 코드를 먼저 살펴보겠습니다.

private func rotateView() {
    for imageView in imageViews {
        let rotation = CABasicAnimation(keyPath: "transform.rotation.z")
        rotation.toValue = Double.pi * 2 * [1, -1].randomElement()!// 2pi
        rotation.duration = option.rotationSpeed
        rotation.repeatCount = Float.infinity
        imageView.layer.add(rotation, forKey: "rotationAnimation")
    }
}

먼저 imageViews라는 변수는 사용자가 등록한 이미지를 사용해서 UIImageView를 만들고, 그것들을 저장해놓은 배열입니다. for문으로 순회하면서 하나하나 회전 애니메이션을 적용시켜 주었습니다. toValue를 지정하는 부분에서 한바퀴를 회전시키고, 해당 애니메이션을 반복시켜야하기 때문에 2 pi 값을 주고있습니다. 값이 양수가 되면 시계방향으로 회전하고, 음수가 되면 반시계 방향으로 회전합니다. 저는 사용자가 보기에 애니메이션이 조금 자유분방한(?) 제멋대로 움직이는 모습을 보여주고 싶었기 때문에 회전방향도 랜덤으로 설정해주었습니다.

duration 변수는 애니메이션을 몇초동안 지속할 것인가, 여기서는 한바퀴를 도는데 몇초를 소모할 것인가를 나타내는 변수입니다. 아직 나오지 않은 option변수의 rotationSpeed라는 프로퍼티를 넣고있는데, 이 option 변수는 나중에 소개할 사용자가 애니메이션을 커스텀할 수 있는 구조체 인스턴스 입니다.

이미지 이동시키기

이미지 이동시키는거, 그거 그냥 UIView.animate 사용하면 되는것 아닌가? 라고 생각하실 수 있지만 이번 경우에는 좀 특수 했습니다. 경계면에 충돌하였을 때, 이동하는 방향을 바꿔야하는 애니메이션을 이어서 수행해야 했고, 그래서 completion handler를 어떻게 수행해야 하는가에 대해 고민이 있었습니다.

바로 이전단계에 CABasicAnimation을 이용해서 회전애니메이션을 구현하였기 때문에 이동도 같은 방법으로 시도하려 했습니다. 하지만 CABasicAnimation에는 기본적으로 completion handler를 제공할 수 있는 옵션이 있었고, CATransaction을 이용해서 completion handler를 등록할 순 있었지만, 핸들러를 등록하는 메서드가 정적메서드로 구현되어 있어 개별 이미지 뷰에 대해서 핸들러를 적용시키고 싶었던 상황에서 사용하기에는 부적절하다고 판단하였습니다.

따라서 저는 UIView.animate를 이용해서 구현하였습니다. 애니메이션을 테스트로 이동시켜보니, 컴플리션핸들러를 수행하기 전에 애니메이션이 잠시 느려졌다가 중간지점에서 빨라지는 느낌을 받았습니다. 제가원하는 대로 중간에 느려지거나 빨라지는 효과 없이 구현하기 위해서는 animate메서드의 options 매개변수로 .curveLinear 옵션을 제공해주어야 했습니다. UIView.animate 를 사용한 코드를 한번 살펴보겠습니다.

private func moveView(with view: UIView, direction: ProgressDirection) {
      
    let collideInfo = makeCollideInfo(current: view.center, direction: direction)
    let collideDirection = collideInfo.direction
    let nextPoint = collideInfo.nextPoint
    let nextFrame = CGRect(origin: nextPoint, size: CGSize(width: option.imageSize, height: option.imageSize))
    let duration = makeDurationTime(current: view.center, next: nextPoint)
    
    UIView.animate(
        withDuration: duration,
        delay: 0,
        options: [.curveLinear]) { [weak self] in
            view.center = nextPoint
        } completion: { [weak self] finished in
            let newDirection = self?.makeNextDirection(current: direction, collide: collideDirection)
            self?.moveView(with: view, direction: newDirection!)
        }
}

**moveView(with:direction:)** 메서드는 뷰 하나를 움직이고, 다음 충돌지점의 좌표를 계산하여 다음 애니메이션 컴플리션 핸들러를 등록하는 메서드입니다.

아직 소개하지 않은 점이 많은데 하나씩 차례대로 살펴보겠습니다. 먼저 매개변수를 보시면 view와 direction이 있습니다. view는 이동하게 할 UIView 인스턴스를 의미하고, direction은 현재 뷰가 이동하고 있는 방향을 나타내는 열거형입니다.

public enum ProgressDirection {
    case topRight
    case topLeft
    case bottomRight
    case bottomLeft
}

방향이 4개만 존재하는 이유는 여러각도로 이미지가 움직이는 것이 아니라, 45, 135, 225, 315도의 방향으로만 움직이기 때문입니다. moveView 메서드를 호출할 때마다 이 네개의 방향중 랜덤한 방향을 매개변수로 넣어줍니다.

현재 움직이는 방향을 이용해서 animate의 completion handler에서 호출하고 있는 **makeNextDirection(current:collide)** 메서드에서 새로운 방향을 만들어줍니다. 그리고 moveView메서드를 다시 호출하여 이번에는 뷰가 어느방향으로 나아갈지 다시 호출해주었습니다.

이미지 충돌

moveView메서드에서 사용하는 변수를 생성하는 부분을 다시 살펴보겠습니다.

let collideInfo = makeCollideInfo(current: view.center, direction: direction)
let collideDirection = collideInfo.direction
let nextPoint = collideInfo.nextPoint
let nextFrame = CGRect(origin: nextPoint, size: CGSize(width: option.imageSize, height: option.imageSize))
let duration = makeDurationTime(current: view.center, next: nextPoint)

먼저 collideInfo라는 변수가 있습니다. makeCollideInfo라는 메서드를 한번 살펴볼까요?

private func makeCollideInfo(current: CGPoint, direction: ProgressDirection) -> CollideInfo {
        
    /// current는 현재 다루고자 하는 뷰의 중심 좌표이다.
    /// ProgressDirection에 따라 y = x, y = -x 둘 중 하나의 함수를 정하고 다음에 충돌할 좌표를 반환한다.
    let height = self.frame.height
    let width = self.frame.width
    let x = current.x
    let y = height - current.y
    
    switch direction {
    case .topRight, .bottomLeft:
        let k = y - x

        if -k >= 0 && -k <= width && direction == .bottomLeft {
            return CollideInfo(nextPoint: CGPoint(x: -k, y: height), direction: .vertical)
        } else if width+k >= 0 && width+k <= height && direction == .topRight {
            return CollideInfo(nextPoint: CGPoint(x: width, y: height - width - k), direction: .horizontal)
        } else if height-k >= 0 && height-k <= width && direction == .topRight {
            return CollideInfo(nextPoint: CGPoint(x: height-k, y: 0), direction: .vertical)
        } else {
            return CollideInfo(nextPoint: CGPoint(x: 0, y: height-k), direction: .horizontal)
        }
    case .topLeft, .bottomRight:
        let k = x + y

        if k >= 0 && k <= width && direction == .bottomRight {
            return CollideInfo(nextPoint: CGPoint(x: k, y: height), direction: .vertical)
        } else if k >= 0 && k <= height && direction == .topLeft {
            return CollideInfo(nextPoint: CGPoint(x: 0, y: height - k), direction: .horizontal)
        } else if k-height >= 0 && k-height <= width && direction == .topLeft {
            return CollideInfo(nextPoint: CGPoint(x: k - height, y: 0), direction: .vertical)
        } else {
            return CollideInfo(nextPoint: CGPoint(x: width, y: width + height - k), direction: .horizontal)
        }
    }
}

엄청나게 많은 연산을 하고있는 것처럼 보입니다.. 사실 제가 제일 많이 고민했던 부분 중 하나입니다. 반환타입으로는 CollideInfo라는 구조체를 사용하고 있는데 이 구조체는 충돌이 발생했을 때 다음으로 나가야할 좌표(nextPoint)와 충돌이 발생한 위치(direction) 정보를 담고 있습니다. 충돌이 발생한 위치는 수직(vertical)과 수평(horizontal)로만 구분하고 있습니다. 위, 아래와 충돌했다면 vertical이고 왼쪽, 오른쪽 경계와 충돌했다면 horizontal인 상태입니다.

그렇다면 현재 좌표와, 진행하고 있는 방향으로 충돌할 지점을 어떻게 알 수 있을까요? 중학교때 배웠던 함수를 사용해서 해결할 수 있습니다. 이미지가 이동할 수 있는 경로는 좌표계 상에서 기울기가 1이거나 -1인 선형함수상에 있습니다. 입사각이 45도인 방향으로만 이동하기 때문입니다.

image.jpg1image.jpg2

크게 기울기가 -1일때와 1일때 두가지 경우로 크게 나누고, 각각의 경우마다 부딪히는 경우의 수가 4가지, 결국 총 8가지의 경우의 수가 있습니다. 여기서 좌표계를 계산해야하면서 주의해야 하는 점 중, 하나가 실제로 CGPoint로 제공되는 좌표는 화면의 좌측 상단을 기준으로 표현되는 좌표이고, 지금처럼 함수로 표현할 때는 좌측 하단을 기준으로 표현하였기 때문에 수치를 변환하는 과정이 있다는 것에 주의해야합니다.

예를 들어 뷰의 높이를 height라고 하고, 이미지에 있는 노란색 점인(a, b)를 표현하고 싶다면 height에서 제공받은 y좌표를 뺀 값이 b가 될것입니다. x좌표를 나타내는 a는 그대로 사용해도 됩니다.

그렇게 좌표도 다 변환했다면 8가지의 경우의수를 살펴보겠습니다.

  • 이미지의 이동 기울기가 -1인 경우
    1. y = 0 일 때, 0 ≤ x ≤ width 이고, 진행방향이 bottomRight 인 경우
    2. x = 0 일 때, 0 ≤ y ≤ height이고, 진행방향이 topLeft 인 경우
    3. y = height 일 때, 0 ≤ x ≤ width 이고, 진행방향이 topLeft인 경우
    4. x = width 일 때, 0 ≤ y ≤ height 이고, 진행방향이 bottomRight 인경우
  • 이미지의 이동 기울기가 1인 경우
    1. y = 0 일 때, 0 ≤ x ≤ width 이고, 진행방향이 bottomLeft 인 경우
    2. x = width 일 때, 0 ≤ y ≤ height 이고, 진행방향이 topRight 인 경우
    3. y = height 일 때, 0 ≤ x ≤ width 이고, 진행방향이 topRight 인 경우
    4. x = 0 일 때, 0≤ y ≤ height 이고, 진행방향이 bottomLeft 인 경우

위의 8가지 경우의 수를 고려하여 다음 충돌할 좌표와 그때의 충돌 위치를 담은 CollideInfo 구조체 인스턴스를 반환하게 됩니다.

아까 보여드렸던 코드를 다시 보여드리면

let collideInfo = makeCollideInfo(current: view.center, direction: direction)
let collideDirection = collideInfo.direction
let nextPoint = collideInfo.nextPoint
let nextFrame = CGRect(origin: nextPoint, size: CGSize(width: option.imageSize, height: option.imageSize))
let duration = makeDurationTime(current: view.center, next: nextPoint)

전달받은 CollideInfo 구조체의 정보를 바탕으로, 충돌한 방향과, 다음으로 이동할 좌표, 그리고 새로운 프레임 정보를 계산하고 있습니다.

아직 설명하지 않은 부분은 duration 변수입니다. duration은 애니메이션이 수행될 시간을 나타내는 값이고, **makeDurationTime** 메서드가 현재 위치와 다음 위치를 바탕으로 이동할 위치를 결정합니다. 왜 이동하는 시간을 따로 계산해주어야하는 걸까요? 그냥 지정된 고정값을 사용하면 안되는 걸까요?

그 이유는 고정된 값을 사용하면 짧은거리를 이동할때와, 긴 거리를 이동 할때 모두 같은 시간으로 움직이기 때문에 이미지의 이동속도가 일정해지지 않기 때문입니다. 따라서 중학교 때 배우는 거리 = 속도 × 시간 공식을 이용하여 이동시간을 얻는 함수가 **makeDurationTime(current:next:)** 메서드 입니다. 함수를 한번 살펴보겠습니다.

private func makeDurationTime(current: CGPoint, next: CGPoint) -> CGFloat {
    let distance = CGPointDistance(from: current, to: next)
    return distance / option.velocity
}

현재 좌표와 다음 좌표를 이용해서 시간을 반환하는 함수입니다. **CGPointDistance** 메서드는 두 점사이의 거리 공식을 사용하여 거리를 구하는 메서드입니다. 그리고 거리를 속도로 나누어 애니메이션을 수행할 시간을 반환하게 됩니다. option 변수는 **ABBoundingView** 의 옵션 정보를 담고 있는 **ABBoundingConfiguration**구조체 인스턴스입니다. 사용자가 초기화할 때 이 설정 구조체를 지정해줄 수도 있고, 그냥 기본값을 사용할 수도 있습니다. velocity 프로퍼티가 나타내는 값의 의미는 초당 이동할 픽셀 수 입니다.

이렇게 완성된 정보들을 바탕으로 UIView.animate 에 적용하면 충돌하는 애니메이션을 구현할 수 있습니다.

UIView.animate(
	  withDuration: duration,
	  delay: 0,
	  options: [.curveLinear]) { [weak self] in
	      view.center = nextPoint
	  } completion: { [weak self] finished in
	      let newDirection = self?.makeNextDirection(current: direction, collide: collideDirection)
	      self?.moveView(with: view, direction: newDirection!)
	  }

사용법은 다음과 같이 ABBoundingView 인스턴스를 만들고 서브뷰에 추가하고, 원하는 constraint를 적용하시는 것으로 끝입니다!

let boundingView = ABBoundingView(
    images: [
        UIImage(named: "swift-logo"),
        UIImage(named: "ruby-logo"),
        UIImage(named: "kotlin-logo"),
        UIImage(named: "dart-logo"),
        UIImage(named: "python-logo"),
        UIImage(named: "swiftui-logo"),
        UIImage(named: "java-logo"),
    ],
    option: ABBoundingConfiguration(
        imageSize: 120,
        rotationSpeed: 6,
        velocity: 130
    )
)

// ... 

view.addSubview(boundingView)

인스턴스를 생성할 때 option을 통해서 이미지가 이동하는 속도, 회전속도, 이미지의 사이즈를 조절할 수 있습니다. 기본값으로 지정되는 구조체 인스턴스가 있기 때문에 제공하지 않으면 기본값으로 애니메이션이 수행됩니다.

0개의 댓글