Match Zoom Transition - 1 _ 개념 + 등장

김재형_LittleTale·2025년 11월 3일

Swift_Animation

목록 보기
1/3

Match Transition

WWDC2024-Match_Zoom_Transition
오늘은 아래와 같은 전환 애니메이션을 구성해 볼겁니다.
SwiftUI 에서도 가능합니다. 다만, SwiftUI는 자료가 많으니 UIKit를 통해 이를 구현해 보겠습니다.

iOS 18+

WWDC 영상의 방법은 iOS 18이상에 대한 이야기 입니다.
아직은 iOS 16이상인 기종이 꽤 존재 하기 때문에
간단히 다루고 미만 버전을 다루어 보겠습니다.

   private func nextView() {
        let vc = TestZoomNextViewController()
        if #available(iOS 18.0, *) {
            vc.preferredTransition = .zoom { [weak self] context in
                guard let self else { return nil }
                return testButton
            }
        }
        
        self.present(vc, animated: true)
    }

보시는 거와 같이
preferredTransition 의 zoom을 이용하여 간단하게
UIKit 에서도 해당 애니메이션을 구현할 수 있습니다.


iOS 18 under

자 그럼 iOS 18 이하는 못하는 걸까요?
아뇨 구현은 가능합니다.

위 gif 처럼 비슷하게 구현이 가능합니다.

UIViewControllerTransitioningDelegate

A set of methods that vend objects used to manage a fixed-length or interactive transition between view controllers.
뷰 컨트롤러 간의 고정 길이 또는 대화형 전환을 관리하는 데 사용되는 객체를 벤더하는 일련의 방법입니다.

말이 참 어렵단 말이죠.
"이 뷰컨을 모달로 띄울 때, 어떤 애니메이션·제스처·레이아웃을 쓸지 정할께요"

Methods

animationController _presented

func animationController(
    forPresented presented: UIViewController,
    presenting: UIViewController,
    source: UIViewController
) -> UIViewControllerAnimatedTransitioning?

모달을 등장할때에 애니메이터를 반환합니다.

interactionControllerForPresentation

func animationController(
    forDismissed dismissed: UIViewController
) -> UIViewControllerAnimatedTransitioning?

프렌젠트에서 제스처를 제공할 애니메이터 반환 합니다.

animationController _dismiss

func animationController(
    forDismissed dismissed: UIViewController
) -> UIViewControllerAnimatedTransitioning?

dismiss 시에 사용할 애니메이터를 반환 합니다.

interactionControllerForDismissal

func interactionControllerForDismissal(
    using animator: UIViewControllerAnimatedTransitioning
) -> UIViewControllerInteractiveTransitioning?

드래그를 통한 제스처에 대한 뒤로가기 애니메이션 반환

presentationController

func presentationController(
    forPresented presented: UIViewController,
    presenting: UIViewController?,
    source: UIViewController
) -> UIPresentationController?

뷰 계층/딤뷰/코너/시트 높이 같은 레이아웃 관리 컨틀롤러를 반환합니다.

위의 메소드들을 통해서 위와 같은 Zoom Transition 을 구성해 보도록 하겠습니다.

CustomZoomTransitionConfiguration

기본제공되는 그런건 아닙니다.
애니메이션을 만들때에 사용할 기본적인 구조를 만들어 보겠습니다.

/// ZOOM 전환(프레젠트/디스미스) 시 사용되는 설정 값 모음
/// - 전환 시간, 스프링 파라미터
/// - 팬 제스처(양축) 기반 알파/디스미스 임계값 계산
struct CustomZoomTransitionConfiguration {
    // MARK: - 애니메이션 기본 파라미터
    /// 전환(프레젠트/디스미스)에 걸리는 총 시간
    let transitionDuration: TimeInterval
    /// 스프링 애니메이션의 감쇠 비율 --> (작을수록 더 출렁임)
    let springWithDamping: CGFloat
    /// 스프링 애니메이션의 초기 속도
    let initialSpringVelocity: CGFloat

    // MARK: - 팬 제스처 관련 파라미터
    /// 이 거리(픽셀) 이상 이동하면 디스미스 처리
    let panGestureDismissThreshold: CGFloat
    /// 팬 제스처 중 최소 알파 값
    let minAlphaDuringPan: CGFloat
    /// 이 거리까지 선형으로 알파가 감소 (0 → minAlphaDuringPan)
    let alphaRangeDistance: CGFloat

    init(
        transitionDuration: TimeInterval = 0.5,
        springWithDamping: CGFloat = 0.85,
        initialSpringVelocity: CGFloat = 0.8,
        panGestureDismissThreshold: CGFloat = 120,
        minAlphaDuringPan: CGFloat = 0.6,
        alphaRangeDistance: CGFloat = 160
    ) {
        self.transitionDuration = transitionDuration
        self.springWithDamping = springWithDamping
        self.initialSpringVelocity = initialSpringVelocity
        self.panGestureDismissThreshold = panGestureDismissThreshold
        self.minAlphaDuringPan = minAlphaDuringPan
        self.alphaRangeDistance = alphaRangeDistance
    }
}

CustomPresentingTransitionAnimator

자 이번엔 뷰가 등장할때에 대한 애니메이션을 정의 보고자 합니다.

final class CustomPresentingTransitionAnimator: NSObject {
    private let config: CustomZoomTransitionConfiguration
    private let referenceView: UIView
    private var transitionContext: UIViewControllerContextTransitioning?

    // MARK: - 초기화
    init(config: CustomZoomTransitionConfiguration, referenceView: UIView) {
        self.config = config
        self.referenceView = referenceView
    }
}
extension CustomPresentingTransitionAnimator: UIViewControllerAnimatedTransitioning {
    
    /// 프레젠트 전환에 소요되는 총 시간을 반환
    func transitionDuration(
        using transitionContext: UIViewControllerContextTransitioning?
    ) -> TimeInterval {
        print("\(#function)")
        return config.transitionDuration
    }

    /// 버튼 위치/ 크기에서 시작해 화면 전체로 확대되는 프레젠트 애니메이션.
    func animateTransition(
        using transitionContext: UIViewControllerContextTransitioning
    ) {
        print("\(#function)")
        guard let presentedViewController = transitionContext.viewController(forKey: .to) else {
            return
        }

        self.transitionContext = transitionContext

        let containerView = transitionContext.containerView

        // 배경 디밍 뷰를 준비하고 컨테이너에 추가
        let backgroundView = makeBackgroundView(in: containerView)

        // 최종 프레임과 버튼(referenceView)의 시작 프레임을 계산
        let finalFrame = containerView.bounds
        let refFrameInContainer = containerView.convert(referenceView.bounds, from: referenceView)

        // 프레젠트될 뷰를 초기 상태(작게/버튼 위치)로 설정
        let presentedView = presentedViewController.view!
        preparePresentedView(presentedView,
                             finalFrame: finalFrame,
                             refFrameInContainer: refFrameInContainer)

        // 컨테이너에 프레젠트 뷰를 추가합니다(디밍 뷰 위).
        containerView.addSubview(presentedView)

        // 확대 애니메이션
        animatePresentation(backgroundView: backgroundView,
                            presentedView: presentedView,
                            finalFrame: finalFrame,
                            transitionContext: transitionContext)
    }

    /// 프레젠트 애니메이션 종료 시 호출됩니다.
    func animationEnded(_ transitionCompleted: Bool) {
        print("\(#function)")
        transitionContext = nil
    }
}

private extension CustomPresentingTransitionAnimator {
    
    /// 컨테이너에 디밍(배경) 뷰를 생성/추가하고 반환
    func makeBackgroundView(in containerView: UIView) -> UIView {
        let backgroundView = UIView(frame: containerView.bounds)
        backgroundView.backgroundColor = containerView.backgroundColor
        backgroundView.alpha = 0
        containerView.addSubview(backgroundView)
        return backgroundView
    }

    /// 프레젠트될 뷰를 버튼 위치/크기에서 시작하도록 초기 상태를 설정
    /// - 최종 프레임, 버튼 프레임을 기반으로 스케일/센터/코너 라운드를 구성
    func preparePresentedView(_ presentedView: UIView,
                              finalFrame: CGRect,
                              refFrameInContainer: CGRect) {
        // 최종 프레임을 기준으로 레이아웃 설정
        presentedView.frame = finalFrame

        // 확대/축소 중 코너 라운드가 잘 보이도록 마스킹 활성화
        presentedView.layer.masksToBounds = true
        let referenceCorner = referenceView.layer.cornerRadius

        // 버튼 대비 전체 화면의 스케일 비율 계산
        let minScale: CGFloat = 0.001 // 0 스케일 방지
        let scaleX = max(refFrameInContainer.width / max(finalFrame.width, 1), minScale)
        let scaleY = max(refFrameInContainer.height / max(finalFrame.height, 1), minScale)

        // 스케일 상태에서 버튼 코너와 유사하게 보이도록 초기 코너 보정
        let effectiveScale = max(min(scaleX, scaleY), 0.001)
        let initialCorner = referenceCorner / effectiveScale
        presentedView.layer.cornerRadius = initialCorner

        // 버튼 중심 위치에서 작게 시작하도록 설정
        presentedView.transform = CGAffineTransform(scaleX: scaleX, y: scaleY)
        presentedView.center = CGPoint(x: refFrameInContainer.midX, y: refFrameInContainer.midY)
    }

    /// 디밍 알파/스케일/센터/코너 라운드를 애니메이션하여 화면 전체로 확대
    func animatePresentation(backgroundView: UIView,
                             presentedView: UIView,
                             finalFrame: CGRect,
                             transitionContext: UIViewControllerContextTransitioning) {
        UIView.animate(
            withDuration: config.transitionDuration,
            delay: 0,
            usingSpringWithDamping: config.springWithDamping,
            initialSpringVelocity: config.initialSpringVelocity,
            options: [.beginFromCurrentState]
        ) {
            backgroundView.alpha = 1
            presentedView.transform = .identity
            presentedView.center = CGPoint(x: finalFrame.midX, y: finalFrame.midY)
            presentedView.layer.cornerRadius = 0
        } completion: { _ in
            backgroundView.removeFromSuperview()
            transitionContext.completeTransition(transitionContext.transitionWasCancelled == false)
        }
    }
}

전환될 뷰컨을 반환받기

guard let presentedViewController = transitionContext.viewController(forKey: .to) else {
      return
 }

1편을 마치며

기본적인 개념과, 등장 애니메이션을 만들어 보았습니다.
다음 편은 사라질때에 대한 애니메이션을 구성해보겠습니다.

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

0개의 댓글