Match Zoom Transition - 2 : dismiss

김재형_LittleTale·2025년 11월 6일

Swift_Animation

목록 보기
2/3

들어가기에 앞서

이전편을 보고 오셔야 합니다.

뒤로가기에 대한 애니메이션

/// 디스미스 시 사용할 애니메이터 객체를 반환
    func animationController(
        forDismissed dismissed: UIViewController
    ) -> UIViewControllerAnimatedTransitioning? {
        print("\(#function)")
        return dismissalTransitionAnimator
    }

위의서 반환하는 뒤로가기 애니메이터를 제작하겠습니다.

초기화 구성

final class CustomDismissalTransitionAnimator: NSObject {
    private let config: CustomZoomTransitionConfiguration
    private let referenceView: UIView

    // MARK: - 초기화
    init(config: CustomZoomTransitionConfiguration, referenceView: UIView) {
        self.config = config
        self.referenceView = referenceView
    }
}

주입 받아야 하는 멤버는 다음과 같습니다.

  • 컨피규레이션
  • 돌아갈 타겟 뷰

구성해야 하는 것은 다음과 같습니다.

  • UIViewControllerContextTransitioning

UIViewControllerContextTransitioning

전편에서도 잠깐 나왔었는데 이번편에서 설명하겠습니다.

UIViewControllerAnimatedTransitioning 를 채택한 클래스 즉 객체에게
지금 전환 상황이 어떤지 알려주는 객체입니다. 프로토콜로 구성되있습니다.

-- 구성 --
@MainActor public protocol UIViewControllerAnimatedTransitioning : NSObjectProtocol {

    func transitionDuration(using transitionContext: (any UIViewControllerContextTransitioning)?) -> TimeInterval

    func animateTransition(using transitionContext: any UIViewControllerContextTransitioning)

    /// A conforming object implements this method if the transition it creates can
    /// be interrupted. For example, it could return an instance of a
    /// UIViewPropertyAnimator. It is expected that this method will return the same
    /// instance for the life of a transition.
    @available(iOS 10.0, *)
    optional func interruptibleAnimator(using transitionContext: any UIViewControllerContextTransitioning) -> any UIViewImplicitlyAnimating

    optional func animationEnded(_ transitionCompleted: Bool)
}

-- 집중 --
func animateTransition(using transitionContext: UIViewControllerContextTransitioning)

위 메서드를 통해 해당 객체를 받아올 수 있습니다.

지금 뷰컨과 다음 뷰컨을 알수가 있다. (Optional)

let fromVC = transitionContext.viewController(forKey: .from)
let toVC   = transitionContext.viewController(forKey: .to)

애니메이션을 올릴 뷰를 반환 받을 수 있다.

let containerView = transitionContext.containerView


디스미스 전환시 소요되는 시간 적용

func transitionDuration(
        using transitionContext: UIViewControllerContextTransitioning?
    ) -> TimeInterval {
        print("\(#function)")
        return config.transitionDuration
    }

모달 방식에서의 인터렉티브 애니메이션

이해해보기

사용자가 뷰를 제스처를 통해서 이전 뷰 위치로 돌아가는 형태입니다.
햇갈리시면 않되는 것이 제스처 도중에는 Dismiss가 된 상황이 아닙니다.
즉 시작하는순간 해당 함수에서 최종으로 내릴건지 다시 원위치 할지를 판단해야 합니다.

UIViewControllerInteractiveTransitioning 구현

startInteractiveTransition( _:) 을 통해

func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) {
    print(#function)
    
    // 1. 전환에 참여하는 뷰컨을 가져온다.
    //   사라질(from) 쪽을 기준으로 (사용자가 드래그 하는 뷰)
    guard let fromVC = transitionContext.viewController(forKey: .from) else {
        return
    }

    // 2. 원래 뷰는 숨김 -> 안하면 돌아가는 애니메이션 중 기존뷰도 잠깐 보이다 사라짐
    fromVC.view.isHidden = true
    
    // 3. 전환은 항상 containerView 위에서 일어나야 함.
    let containerView = transitionContext.containerView
    
    // 4. 애니메이션이 시작될 위치/크기 = 현재 from 뷰의 프레임
    let startPoint = CGPoint(x: fromVC.view.frame.minX,
                             y: fromVC.view.frame.minY)
    let startSize = fromVC.view.frame.size

    // 5. 실제로 움직일 임시 뷰(transitionView)를 만든다.
    //    - 시작 프레임은 from 뷰와 동일
    //    - 색상도 from 뷰와 맞춰서 자연스럽게
    //    - cornerRadius는 일단 0에서 시작
    let transitionView = UIView(frame: CGRect(origin: startPoint, size: startSize))
    transitionView.backgroundColor = fromVC.view.backgroundColor
    transitionView.layer.masksToBounds = true
    transitionView.layer.cornerRadius = 0
    containerView.addSubview(transitionView)

    // 6. 애니메이션이 끝났을 때 도달해야 하는 최종 프레임을 구한다.
    //    기준 뷰(referenceView)의 bounds를
    //    containerView 좌표로 변환해서 해당 위치로 가게 하는 구조.
    let finalFrame = containerView.convert(referenceView.bounds, from: referenceView)

    // 7. 스프링 애니메이션으로 뷰를 최종 위치로 늘리면서
    //    cornerRadius도 대상 뷰와 동일하게 맞춰준다.
    UIView.animate(withDuration: config.transitionDuration,
                   delay: 0,
                   usingSpringWithDamping: config.springWithDamping,
                   initialSpringVelocity: config.initialSpringVelocity,
                   options: [.beginFromCurrentState]) { [weak self] in
        guard let self else { return }
        
        transitionView.frame = finalFrame
        transitionView.layer.cornerRadius = referenceView.layer.cornerRadius
    } completion: { _ in
        // 8. 전환이 끝났다고 시스템에 알려준다.
        // transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        //    처럼 취소 여부를 반영해주는 게 안전하다.
        transitionContext.completeTransition(true)
    }
}

제스처에 따른 뷰 크기 위치 조작

// MARK: - 제스처 처리
    /// 프레젠트된 화면에서 팬 제스처를 감지해 이동/알파/디스미스를 제어
    @objc
    private func handlePanGesture(_ gesture: UIPanGestureRecognizer) {
        guard let presentedVC = presentationController?.presentedViewController,
              let container = presentedVC.view.superview else {
            return
        }

        guard let targetView = presentedVC.view else { return }

        switch gesture.state {
        case .began:
            targetView.layer.cornerRadius = 45
            // 1) 기존 뷰의 중앙, 포인트를 저장
            originalCenter = targetView.center
            originalAnchor = targetView.layer.anchorPoint

            // 2) 터치 지점을 뷰 좌표로
            let touchInView = gesture.location(in: targetView)

            // 3) anchorPoint로 바꿀 비율 (0~1)
            let newAnchor = CGPoint(
                x: touchInView.x / targetView.bounds.width,
                y: touchInView.y / targetView.bounds.height
            )
            // 0.5, 0.5 (중심) --> 드래그 앵커
            targetView.layer.anchorPoint = newAnchor
            
            // 4) anchorPoint를 바꾸면 position이 바뀌어버리니, 그만큼 보정
            let oldPos = targetView.layer.position
            print("began - oldPos: ", oldPos) // began - oldPos:  (220.0, 478.0)
            print("began - originalAnchor", originalAnchor) // began - originalAnchor (0.5, 0.5)
            print("began - newAnchor", newAnchor) // began - newAnchor (0.02727272727272727, 0.3158995815899582)
            
            let moveX = (newAnchor.x - originalAnchor.x) // 기준점x 0.47..만큼 왼쪽으로
            let moveY = (newAnchor.y - originalAnchor.y) // 기준점y 0.19..만큼 위로
            
            // px 로 전환
            let moveXPx = moveX * targetView.bounds.width
            let moveYPx = moveY * targetView.bounds.height
            
            let newPos = CGPoint(
                x: oldPos.x + moveXPx,
                y: oldPos.y + moveYPx
            )
            
            targetView.layer.position = newPos

        case .changed:
            let translation = gesture.translation(in: container)
            let dx = translation.x
            let dy = translation.y

            // 전체 이동 거리 (피타고라스)
            let distance = hypot(dx, dy)

            // 얼마나 줄일지
            let maxDrag: CGFloat = 300
            let progress = min(distance / maxDrag, 1)

            let minScale: CGFloat = 0.4
            let scale = 1 - (1 - minScale) * progress

            let scaleTransform = CGAffineTransform(scaleX: scale, y: scale)
            let translateTransform = CGAffineTransform(translationX: dx, y: dy)
            targetView.transform = scaleTransform.concatenating(translateTransform)

            // 투명도도 전체 거리 기준
            targetView.alpha = 1 - 0.4 * progress

        case .ended, .cancelled:
            // 원래대로
            let shouldDismiss = targetView.transform.ty > 150
            if shouldDismiss {
                presentedVC.dismiss(animated: true)
            } else {
                UIView.animate(withDuration: 0.2, animations: { [weak self] in
                    guard let weakSelf = self else { return }
                    targetView.transform = .identity
                    targetView.alpha = 1
                    // anchorPoint도 원래대로
                    targetView.layer.anchorPoint = weakSelf.originalAnchor
                    targetView.center = weakSelf.originalCenter
                    targetView.layer.cornerRadius = 0
                })
            }

        default:
            break
        }
    }

GitHub

Little-tale/SwiftAnimations

ZoomTransition 4번째 폴더

마치면서

이번 편은 정말 꽤 흥미로운 내용이였다고 생각합니다.
아직 끝이 아닌게
네비게이션 방식일땐 지금 같이 해두어도 동작을 안합니다.
다음편은 네비게이션 방식에서도 해보는것, 그리고, 그 다음편은 WebRTC 편을 다루어 보도록 하겠습니다.
감사힙니다.

profile
IOS 개발자 새싹이, 작은 이야기로부터

0개의 댓글