-
[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