ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Image Transition] 썸네일 -> 디테일 샷
    재미있는 UI (Swift) 2024. 6. 10. 16:06

     

     

     

     

     

    예전부터 해보고 싶던 이미지 디테일 트랜지션

     

    SwiftUI로는 도전해보고 간단히 넘어갔는데, 이번에 Swift로 만든 앱을 업데이트하면서 도전해보기로 했다.

     

    포인트를 잡고 넘어가보자.

    1. 이건 Modal View다.
    자주 사용하는 명령어 present(detailView, animation: true)로 디테일뷰를 띄우되, custom으로 효과를 준 거다.


    2. 그럼 커스텀을 하기 위해 해야 하는 것은 무엇이냐,
    2-1. 최종 화면을 UIViewController로 작성한다.
    2-2. 기본 모달뷰 대신 UIViewcontroller를 띄워줄 커스텀 모달뷰를  UIPresentationController로 작성해 준다.
    2-3. 그리고  만들어 주고 싶은 애니메이션을 UIViewControllerAnimatedTransitioning으로 작성해 준다. 


    3. 종합하면

    홈 화면(테이블 뷰) / 테이블 셀 / 띄울 최종 화면
    + (최종 화면을 띄어줄 커스텀 모달뷰 PresentationController)
    + (애니메이션을 지정해 줄 AnimationTransition)

    이정도 되시겠다.

     

     

     

     

    1. 우선 홈 화면에 테이블 뷰 / 테이블 셀을 준비한다.

     

    존경하는 snapkit님을 이용해드리면 별 어려움 없는 코드다.

    테이블 선택시 present 할 화면은 이따 만들어줄 DetailVC를 띄어야 하니 일단은 임시로 지정해준다.

     

    import UIKit
    import SnapKit
    
    class HomeViewController: UIViewController {
        let image = ["zd01.jpg", "zd02.jpg", "zd03.jpg", "zd04.jpg", "zd05.jpg", "zd06.jpg"]
        let tableView = UITableView(frame: .zero, style: .insetGrouped)
        
        override func viewDidLoad() {
            super.viewDidLoad()
            attribute()
            layout()
        }
        
        func attribute() {
            tableView.delegate = self
            tableView.dataSource = self
            tableView.register(ImageCell.self, forCellReuseIdentifier: "ImageCell")
        }
        func layout() {
            self.view.addSubview(tableView)
            tableView.snp.makeConstraints {
                $0.edges.equalToSuperview()
            }
        }
    }
    
    extension HomeViewController: UITableViewDelegate, UITableViewDataSource {
        func numberOfSections(in tableView: UITableView) -> Int {
            return self.image.count
        }
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            return 1
        }
        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            guard let cell = tableView.dequeueReusableCell(withIdentifier: "ImageCell",
                                                           for: indexPath) as? ImageCell
            else { return UITableViewCell() }
            cell.settingCell(image: self.image[indexPath.section])
            return cell
        }
        func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
            return 100
        }
        
        func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
            guard let cell = tableView.cellForRow(at: indexPath) as? ImageCell else { return }
            // let detailView = DetailViewController(image: cell.imgView.image)
            let detailView = UIViewController()
            self.present(detailView, animated: true)
        }
    }

     

    import UIKit
    import SnapKit
    
    class ImageCell: UITableViewCell {
        let imgView = UIImageView()
        
        func settingCell(image: String) {
            imgView.image = UIImage(named: image)
            imgView.contentMode = .scaleAspectFit
            imgView.clipsToBounds = true
    
            self.contentView.addSubview(imgView)
            
            imgView.snp.makeConstraints {
                $0.center.horizontalEdges.equalToSuperview()
            }
        }
    }

     

     

     

     

     

    2. 그리고 최종 나타날 화면도 준비한다.

     

    2-1. 터치한 셀과 동일한 이미지를 받기 위해 init(image: UIImage!)으로 이미지를 받는다.

     

    2-2. 이 모달뷰의 presentationStyle.custom으로 지정해줘야 하는데, 이는 init 단계에서 잡아줘야 한다.

    (viewDidLoad, viewWillApper에서 천천히 가라고 외쳐봤자 이미 님은 겁나게 달려 강을 건너간 후인 거 같다.)

    아니면 홈화면에서 지정하고 present 해도 되긴 하는데, 이따 이 단계에서 delegate를 지정해 줄 것이란 것만 참고하자.

     

    2-3. 추가로 화면을 닫을 버튼을 달아줬다. 실전에서는 Swipe 동작으로 닫아주는 게 사용자 측면에서 더 좋을 거 같다.

     

     

    import UIKit
    import SnapKit
    
    class DetailViewController: UIViewController {
        let image: UIImage!
        let imageView = UIImageView()
        let btnclose = UIButton()
        
        init(image: UIImage!) {
            self.image = image
            super.init(nibName: nil, bundle: nil)
            settingView()
        }
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        
        func settingView() {
            modalPresentationStyle = .custom
        }
    
        override func viewDidLoad() {
            super.viewDidLoad()
            attribute()
            layout()
        }
        
        func attribute() {
            imageView.image = image
            imageView.contentMode = .scaleAspectFit
            
            btnclose.setTitle("close", for: .normal)
            btnclose.setTitleColor(.orange, for: .normal)
            btnclose.addTarget(self, action: #selector(closeTheDoor), for: .touchUpInside)
        }
        func layout() {
            [imageView, btnclose].forEach {
                self.view.addSubview($0)
            }
            imageView.snp.makeConstraints {
                $0.center.horizontalEdges.equalToSuperview()
            }
            btnclose.snp.makeConstraints {
                $0.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom)
                $0.trailing.equalToSuperview().inset(10)
                $0.width.height.equalTo(50)
            }
        }
        @objc
        func closeTheDoor() {
            self.dismiss(animated: true)
        }
    }

     

     

     

     

     

    # 중간점검

     

    이렇게 기본을 완성하고 한 번 실행해보면

     

    마치 기존 모달뷰처럼 나오는데, 배경이 없다.

    이유는, 우리가 presentationStyle.custom으로 지정했으면서 모달뷰 형태를 잡아주지 않았기 때문에 나타나는 것이다.

     

    참고로, 다 만들고도 혹시 이 상태라면 그건 만든 모달뷰를 인식하는 데 문제가 있다는 거다.

    다시 말해, "이 화면이 나타났다" = "모달뷰 구현 코딩에 뭔가 문제가 있다."로 기억하자.

     

     

     

     

     

     

    3. 이제 디테일뷰를 전달해줄 커스텀 모달뷰를 만들어보자.

     

     

    3-1. 여기서 해야 할 일은 배경 뷰를(나는 블러뷰로) 지정해주고 모달뷰를 작동함에 따라 배경뷰의 투명도를 조정해 줄 거다.

    3-2. 알아둬야 할 용어는 Presenting(모달뷰를 호출한 뷰: HomeVC)와 Presented(모달뷰가 나타내줄 뷰: DetailVC)다.

     

     

    자 이제 하나씩 살펴보면

    3-3-0. 크게 두 부분으로 나눠 1️⃣Presentation 시작/완료 시의 코드, 2️⃣DisMiss 시작/완료 시의 코드로 나눌 수 있다.

    import UIKit
    import SnapKit
    
    class PresentationController: UIPresentationController {
        private lazy var blurView = UIVisualEffectView(effect: nil)
        
        override func presentationTransitionWillBegin() {
            let container = containerView!
            
            blurView.translatesAutoresizingMaskIntoConstraints = false
            container.addSubview(blurView)
            blurView.snp.makeConstraints {
                $0.edges.equalToSuperview()
            }
            
            blurView.effect = UIBlurEffect(style: .light)
            blurView.alpha = 0.0
            
            presentingViewController.beginAppearanceTransition(false, animated: false)
            presentedViewController.transitionCoordinator!.animate(alongsideTransition: { (ctx) in
                self.blurView.alpha = 1
            }) { (ctx) in }
        }
        
        override func presentationTransitionDidEnd(_ completed: Bool) {
            presentingViewController.endAppearanceTransition()
        }
        
        override func dismissalTransitionWillBegin() {
            presentingViewController.beginAppearanceTransition(true, animated: true)
            presentedViewController.transitionCoordinator!.animate(alongsideTransition: { (ctx) in
                self.blurView.alpha = 0.0
            }, completion: nil)
        }
        
        override func dismissalTransitionDidEnd(_ completed: Bool) {
            presentingViewController.endAppearanceTransition()
            if completed {
                blurView.removeFromSuperview()
            }
        }
    }

     

     

    3-3-1. 그럼 우선 나타날 때, 

    UIPresentationController에는 containView(UIView)가 있다.

    여기에 blurView를 넣어 레이 잡고, 블러뷰 효과를 구현한 뒤에 아래 명령으로 블러뷰가 나타날 때 애니메이션 효과를 준다.

    	presentedViewController.transitionCoordinator!.animate(alongsideTransition: { (ctx) in
    		self.blurView.alpha = 1
            }) { (ctx) in }

     

    3-3-2. 반대로 모달 뷰를 닫을 때, 

    반대로 투명도를 0으로 조정하고, 완료되면 블러뷰를 제거해 버리자.

    	
        override func dismissalTransitionWillBegin() {
            presentingViewController.beginAppearanceTransition(true, animated: true)
            presentedViewController.transitionCoordinator!.animate(alongsideTransition: { (ctx) in
                self.blurView.alpha = 0.0
            }, completion: nil)
        }
        
        override func dismissalTransitionDidEnd(_ completed: Bool) {
            presentingViewController.endAppearanceTransition()
            if completed {
                blurView.removeFromSuperview()
            }
        }

     

    * beginAppearanceTransition이나 endAppearanceTransition의 필요성에 대해서는 먼 훗날 같이 얘기해보자.

     

     

    3-3-3. 여기까지 하고, 

    이 DetailView에 이. 트랜지션을 적용하고, delegate를 지정해주도록 하자.

    class DetailViewController: UIViewController {
    
    			🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻
    			            🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻
    
        func settingView() {
            modalPresentationStyle = .custom
            transitioningDelegate = self
        }
        
    		    🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻
    			            🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻🏊🏻
    
    }
    
    extension DetailViewController: UIViewControllerTransitioningDelegate {
        func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
            return PresentationController(presentedViewController: presented, presenting: presenting)
        }
    }

     

    이제 커스텀으로 배경이 있는 모달뷰까지 완성했다.

     

     

     

     

     

    4. 이제 애니메이션 효과를 주도록 해보자.

     

    1. 애니메이션은 나타낼 때와 사라질 때 2개로 나눠서 구분했다.

    import UIKit
    
    enum AnimationType {
        case present
        case dismiss
    }
    
    class AnimationTransition: NSObject {
        let animationType: AnimationType!
        
        init(animationType: AnimationType) {
            self.animationType = animationType
            super.init()
        }
    }

     

     

    2. 이제 이 NSObject에 UIViewControllerAnimatedTransitioning 프로토콜을 달아주면 아래 2 명령어를 기본으로 작성해 줘야 한다.

    extension AnimationTransition: UIViewControllerAnimatedTransitioning {
        func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        	// 애니메이션 시간
        }
        func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        	// 애니메이션 효과
        }
    }

     

     

    3. 애니메이션 효과는 아까 위해서 정한 타입에 따라 2가지로 구분해서 작성한다.

    extension AnimationTransition: UIViewControllerAnimatedTransitioning {
        func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
            return 0.7
        }
        
        func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
            if animationType == .present {
                animationForPresent(using: transitionContext)
            } else {
                animationForDismiss(using: transitionContext)
            }
        }
        
        func animationForPresent(using transitionContext: UIViewControllerContextTransitioning) {
        	// 나타날 때 애니메이션
        }
        
        func animationForDismiss(using transitionContext: UIViewControllerContextTransitioning) {
        	// 사라질 때 애니메이션
        }    
    }

     

     

    4. 이제 나타날 때의 효과를 정의해보면 여기서 애니메이션이 일어나는 주 무대는 transitionContext다.

     

    4-0. 이 context에 있는 fromVC와 toVC 정보를 이용해 마찬가지로 context 있는 containverView에 표현할 것이다.

     

    차근차근 살펴보면, 

    4-1. .from과 .to를 이용해 fromVC(HomeVC)와 toVC(DetailVC) 인스턴스를 만들고

    4-2. fromVC에서 선택한 Cell의 frame을 가져와 toVC에 적용한 후 컨테이너 뷰에 이 toVC.view를 담을 것이다.

    4-3. 마지막으로 toVC의 크기를 애니메이션 효과를 주며 크기를 키우면 된다.

     

    * 이 작업을 위해 HomeView에서 선택한 셀을 가지고 있도록 작업해준다.

    class HomeViewController: UIViewController {
        
        var selectedCell: ImageCell?
        
    }
    
    extension HomeViewController: UITableViewDelegate, UITableViewDataSource {
            
        func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
            guard let cell = tableView.cellForRow(at: indexPath) as? ImageCell else { return }
            
            self.selectedCell = cell
            
            let detailView = DetailViewController(image: cell.imgView.image)
            self.present(detailView, animated: true)
        }
    }

     

        
        func animationForPresent(using transitionContext: UIViewControllerContextTransitioning) {
            let containerView = transitionContext.containerView
            
            //1.Get fromVC and toVC
            guard let fromVC = transitionContext.viewController(forKey: .from) as? HomeViewController,
                let toVC = transitionContext.viewController(forKey: .to) as? DetailViewController else { return }
            guard let selectedCell = fromVC.selectedCell else { return }
    
            let frame = selectedCell.convert(selectedCell.contentView.frame, to: fromVC.view)
            
            
            //2.Set presentation original size.
            toVC.view.frame = frame
            toVC.imageView.frame = frame
            
            containerView.addSubview(toVC.view)
            
            //3.Change original size to final size with animation.
            UIView.animate(withDuration: 0.7, delay: 0, usingSpringWithDamping: 0.85, initialSpringVelocity: 0, options: [], animations: {
                toVC.view.frame = UIScreen.main.bounds
                toVC.imageView.frame.size.width = UIScreen.main.bounds.width
                toVC.imageView.frame.size.height = UIScreen.main.bounds.height
    
            }) { (completed) in
                transitionContext.completeTransition(completed)
            }
        }

     

     

     

     

     

    5. 사라질 때도 마찬가지이니 설명은 생략한다. 나는 당신을 믿는다.

        func animationForDismiss(using transitionContext: UIViewControllerContextTransitioning) {
            guard let fromVC = transitionContext.viewController(forKey: .from) as? DetailViewController,
                let toVC = transitionContext.viewController(forKey: .to) as? HomeViewController else { return }
            guard let selectedCell = toVC.selectedCell else { return }
            
            fromVC.imageView.frame.size.width = UIScreen.main.bounds.width
            fromVC.imageView.frame.size.height = UIScreen.main.bounds.height
            
            UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.95, initialSpringVelocity: 0, options: [], animations: {
                let frame = selectedCell.convert(selectedCell.contentView.frame, to: toVC.view)
                fromVC.view.frame = frame
                fromVC.view.layer.cornerRadius = 10
                fromVC.imageView.frame.size.width = frame.width
                fromVC.imageView.frame.size.height = frame.height
                fromVC.btnclose.alpha = 0
            }) { (completed) in
                transitionContext.completeTransition(completed)
            }
        }

     

     

     

    6. 자 이제 마지막으로 DetailView의 Extenstion을 완성해 주면 된다.

    extension DetailViewController: UIViewControllerTransitioningDelegate {
        func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> (any UIViewControllerAnimatedTransitioning)? {
            return AnimationTransition(animationType: .present)
        }
        func animationController(forDismissed dismissed: UIViewController) -> (any UIViewControllerAnimatedTransitioning)? {
            return AnimationTransition(animationType: .dismiss)
        }
        func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
            return PresentationController(presentedViewController: presented, presenting: presenting)
        }
    }

     

     

    정리하는 데 꽤 오래 걸린다.

    그래도 역시 정리해야 머릿 속에 남는 것 같으니.

    '재미있는 UI (Swift)' 카테고리의 다른 글

    [TextField] placeholder Animaition  (1) 2024.06.06
Designed by Tistory.