iOS

iOS) Appstore Transition 따라하기(1) - 기본 구성

ScutiUY 2020. 12. 2. 07:50

우리가 알게 모르게 익숙해져 있는 Appstore의 Transition을 구현 해보고자 한다.

 

 

생각 보다 복잡해 보인다...

애니메이션은 나중에 생각하고 화면의 구성부터 생각해보자.

 

  • View와 ViewController

아래 tab bar와 기본적으로 메인 viewController, cell을 터치시 생성 되는 Viewcontroller.

collectionView와 collectionView cell로 구성하자.

 

TabBarController - main viewController - tableView - contentView

                             ㄴ DummyVC

 

시작 VC는 tab bar controller이다.

menu화면인 menuVC와 tabbar의 구색을 갖추기 위한 더미 VC를 하나 만들어준 뒤 tabbarVC에 넣어주자.

 

import UIKit
import SnapKit

class TabBarViewController: UITabBarController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        print("Tabbar",#function)
        
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        print("Tabbar",#function)
        let vc1 = AppStoreMenuViewController()
        let vcTabBarItem = UITabBarItem(title: "투데이", image: nil, tag: 0)
        
        let vc2 = SecondViewController()
        let vc2TabBarItem = UITabBarItem(title: "설정", image: nil, tag: 1)
        
        vc2TabBarItem.isEnabled = false
        
        vc1.tabBarItem = vcTabBarItem
        vc2.tabBarItem = vc2TabBarItem
        
        let controllers = [vc1,vc2]
        
        self.viewControllers = controllers
    }
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
    }
    
    override func viewWillLayoutSubviews() {
        GlobalConstants.safeAreaLayoutTop = view.safeAreaInsets.top
    }
    
}

 

menuVC는 collectionView로 이루어져 있다.

collectionView의 cell에는 contentView라는 customView를 만들어서 나중에 재활용 할 수 있도록 한다.

 

 

import UIKit
import SnapKit


class AppStoreMenuViewController: UIViewController {
    
    var appStoreTransition = AppContentTransitionController()
    
    lazy var appCollectionView: UICollectionView = {
        
        let layout = UICollectionViewFlowLayout()
        let cellWidth = view.frame.size.width * 0.9
        let cellHeight = cellWidth * 1.2
        
        var collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        
        layout.itemSize = CGSize(width: cellWidth, height: cellHeight)
        layout.sectionInset.top = 10
        
        collectionView.delaysContentTouches = false
        collectionView.dataSource = self
        collectionView.delegate = self
        collectionView.backgroundColor = .clear
        collectionView.register(AppCollectionViewCell.self, forCellWithReuseIdentifier: "AppCollectionViewCell")
    
        return collectionView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.view.backgroundColor = .white
        setLayout()
    }
    
    func setLayout() {
        
        view.addSubview(appCollectionView)
        
        appCollectionView.snp.makeConstraints { (const) in
            const.top.equalTo(view.safeAreaLayoutGuide.snp.top)
            const.width.equalToSuperview()
            const.bottom.equalToSuperview()
        }
        
    }
}

extension AppStoreMenuViewController: UICollectionViewDelegate,UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return model.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = appCollectionView.dequeueReusableCell(withReuseIdentifier: "AppCollectionViewCell", for: indexPath) as! AppCollectionViewCell
        cell.fectchData(model: model[indexPath.row])
        return cell
    }
    
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let appContentVC = AppContentViewController()
        
        appStoreTransition.indexPath = indexPath
        appStoreTransition.superViewcontroller = appContentVC
        
        appContentVC.fetchData(model: model[indexPath.row])
        appContentVC.modalPresentationStyle = .custom
        appContentVC.transitioningDelegate = appStoreTransition
        appContentVC.modalPresentationCapturesStatusBarAppearance = true
        
        self.present(appContentVC, animated: true, completion: nil)
        
    }
}
import UIKit
import SnapKit

class AppCollectionViewCell: UICollectionViewCell {
  
    func resetTransform() {
        transform = .identity
    }

    lazy var appContentView: AppContentView = {
        var view = AppContentView(isContentView: false)
        view.layer.cornerRadius = 20
        view.contentMode = .center
        return view
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.configureCellLayout()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        self.configureCellLayout()
    }
    
    func configureCellLayout() {
        
        self.clipsToBounds = true
        self.layer.cornerRadius = 20
        
        contentView.addSubview(appContentView)
        
        appContentView.snp.makeConstraints { (const) in
            const.top.equalToSuperview()
            const.bottom.equalToSuperview()
            const.leading.equalToSuperview()
            const.trailing.equalToSuperview()
        }
    }
    
    func fectchData(model: AppContentModel) {
        appContentView.fetchDataForCell(image: model.image, subD: model.subDescription!, desc: model.description!)
    }

 

재사용을 위한 AppContentView는 cell에 사용 되는 View와 cell을 터치했을 때 보여지는 contentVC를 위한 View로 분기 시켜 구성 해준다.

 

import UIKit
import SnapKit

class AppContentView: UIView {
    
    //view 선언부
    
    // MARK:- init
    init(isContentView: Bool, isTransition: Bool = false) {
        super.init(frame: .zero)
        // for reusable
        if isContentView {
            self.setLayoutForContentVC(isTransition: isTransition)
        } else {
            self.setLayoutForCollectionViewCell()
        }
        self.backgroundColor = .white
        scrollView.delegate = self
    }
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.setLayoutForCollectionViewCell()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        self.setLayoutForCollectionViewCell()
    }
    
    // MARK:- set Layout for CollectionViewCell
    func setLayoutForCollectionViewCell(image: UIImage? = nil, subDescription: String? = "", description: String? = "") {
        
        self.addSubview(imageView)
        self.addSubview(subDescriptionLabel)
        self.addSubview(descriptionLabel)
        
        imageView.snp.makeConstraints { (const) in
            const.centerX.equalToSuperview()
            const.centerY.equalToSuperview()
            const.width.equalToSuperview()
            const.height.equalToSuperview()
        }
        subDescriptionLabel.snp.makeConstraints { (const) in
            const.top.equalToSuperview().offset(15)
            const.leading.equalToSuperview().offset(15)
            const.width.equalTo(self.snp.width).multipliedBy(0.8)
        }
        descriptionLabel.snp.makeConstraints { (const) in
            const.top.equalTo(subDescriptionLabel.snp.bottom).offset(10)
            const.leading.equalToSuperview().offset(15)
            const.width.equalTo(self.snp.width).multipliedBy(0.8)
        }
        
    }
    // MARK:- set Layout for PresentedView
    func setLayoutForContentVC(isTransition: Bool = false) {
        
        self.addSubview(scrollView)
        
        scrollView.addSubview(imageView)
        scrollView.addSubview(subDescriptionLabel)
        scrollView.addSubview(descriptionLabel)
        scrollView.addSubview(closeButton)
        scrollView.addSubview(contentText)
        
        closeButton.addTarget(self, action: #selector(close), for: .touchUpInside)
        
        scrollView.snp.makeConstraints { (const) in
            const.centerX.equalTo(self.snp.centerX)
            const.centerY.equalToSuperview()
            const.width.equalToSuperview()
            const.height.equalToSuperview()
        }
        imageView.snp.makeConstraints { (const) in
            const.top.equalTo(scrollView.snp.top)
            const.centerX.equalTo(scrollView.snp.centerX)
            const.width.equalTo(scrollView.snp.width)
            const.height.equalTo(self.snp.width).multipliedBy(0.9 * 1.2).offset(GlobalConstants.safeAreaLayoutTop)
        }
        //iphone 모델 별 safeAreaLayout 적용
        if !isTransition {
            subDescriptionLabel.snp.makeConstraints { (const) in
                const.top.equalTo(imageView.snp.top).offset(GlobalConstants.safeAreaLayoutTop)
                const.leading.equalTo(imageView.snp.leading).offset(20)
                const.width.equalTo(imageView.snp.width).multipliedBy(0.8)
            }
            descriptionLabel.snp.makeConstraints { (const) in
                const.top.equalTo(subDescriptionLabel.snp.bottom).offset(10)
                const.leading.equalTo(imageView.snp.leading).offset(20)
                const.width.equalTo(imageView.snp.width).multipliedBy(0.8)
            }
        } else {
            subDescriptionLabel.snp.makeConstraints { (const) in
                const.top.equalToSuperview().offset(15)
                const.leading.equalToSuperview().offset(15)
                const.width.equalTo(self.snp.width).multipliedBy(0.8)
            }
            descriptionLabel.snp.makeConstraints { (const) in
                const.top.equalTo(subDescriptionLabel.snp.bottom).offset(10)
                const.leading.equalToSuperview().offset(15)
                const.width.equalTo(self.snp.width).multipliedBy(0.8)
            }
        }
        closeButton.snp.makeConstraints { (const) in
            const.top.equalTo(self.snp.top).offset(20)
            const.right.equalTo(self.snp.right).offset(-20)
            const.width.equalTo(30)
            const.height.equalTo(30)
        }
        contentText.snp.makeConstraints { (const) in
            const.top.equalTo(imageView.snp.bottom)
            const.width.equalTo(scrollView.snp.width).multipliedBy(0.95)
            const.bottom.equalTo(scrollView.snp.bottom)
            const.height.equalTo(500)
        }
    }
    
    @objc func close() {
        NotificationCenter.default.post(name: .closeButton, object: nil)
    }
    
    // MARK:- Fetch Data For ContentVC
    func fetchDataForContentVC(image: UIImage?, subD: String, desc: String, content: String, contentView superViewFrame: CGRect, isTransition: Bool = false) {
        imageView.image = image
        subDescriptionLabel.text = subD
        contentText.text = content
        descriptionLabel.text = desc
        
        scrollView.scrollIndicatorInsets = UIEdgeInsets(top: superViewFrame.width * (0.9 * 1.2) + GlobalConstants.safeAreaLayoutTop, left: 0, bottom: 0, right: 0)
        
        let size = CGSize(width: 1000, height: 10000)
        let estimateSize = contentText.sizeThatFits(size)
        
        if !isTransition {
            contentText.snp.remakeConstraints { (const) in
                const.top.equalTo(superViewFrame.width * 0.9 * 1.2 + GlobalConstants.safeAreaLayoutTop)
                const.centerX.equalTo(scrollView.snp.centerX)
                const.width.equalTo(scrollView.snp.width).multipliedBy(0.95)
                const.bottom.equalTo(scrollView.snp.bottom)
                const.height.equalTo(estimateSize.height)
            }
        } else {
            contentText.snp.remakeConstraints { (const) in
                const.top.equalTo(imageView.snp.bottom)
                const.centerX.equalTo(scrollView.snp.centerX)
                const.width.equalTo(scrollView.snp.width).multipliedBy(0.95)
                
                const.bottom.equalTo(scrollView.snp.bottom)
                const.height.equalTo(estimateSize.height)
            }
        }
        
    }
    //MARK:- Fetch Data For CellView
    func fetchDataForCell(image: UIImage?, subD: String, desc: String) {
        imageView.image = image
        subDescriptionLabel.text = subD
        descriptionLabel.text = desc
    }
}

 

 

 

 

collectionView의 cell이 구성되는 과정을 보면 처음 cell을 호출 하고 그 뒤에 데이터를 기존에 구성된 레이아웃에 넣어주는 형식이다.

이미지나 레이블 등의 위치와 크기는 고정 되어 있지만 아래의 메인컨텐츠의 경우 데이터의 크기에 따라 height가 결정된다.

따라서 데이터를 가져올 때 레이아웃을 재구성 해야 한다.

 

 

 

 

초기화시 height에 임의의 값을 넣어주고 fetchDataForContentVC에서 데이터를 가져 올 때 다시 레이아웃을 구성 해준다.

sizeTahtFits 함수를 보면 

 

Asks the view to calculate and return the size that best fits the specified size라고 정의 되어 있다.

즉 알아서 뷰에 알맞는 사이즈를 리턴 해준다.

따라서 임의의 커다란 size를 선언 해주고 view에 적용 시켜주면 알맞는 크기의 사이즈를 리턴 해준다.

 

또한 transition 과정에서도 쓰일 수 있게 각 상황에 맞는 뷰 레이아웃을 구성 해준다.

//처음 레이아웃 구성시
contentText.snp.makeConstraints { (const) in
            const.top.equalTo(imageView.snp.bottom)
            const.width.equalTo(scrollView.snp.width).multipliedBy(0.95)
            const.bottom.equalTo(scrollView.snp.bottom)
            const.height.equalTo(500)
        }
        
// 데이터를 가져올 때
func fetchDataForContentVC(image: UIImage?, subD: String, desc: String, content: String, contentView superViewFrame: CGRect, isTransition: Bool = false) {
        imageView.image = image
        subDescriptionLabel.text = subD
        contentText.text = content
        descriptionLabel.text = desc
        
        scrollView.scrollIndicatorInsets = UIEdgeInsets(top: superViewFrame.width * (0.9 * 1.2) + GlobalConstants.safeAreaLayoutTop, left: 0, bottom: 0, right: 0)
        
        let size = CGSize(width: 1000, height: 10000)
        let estimateSize = contentText.sizeThatFits(size)
        
        if !isTransition {
            contentText.snp.remakeConstraints { (const) in
                const.top.equalTo(superViewFrame.width * 0.9 * 1.2 + GlobalConstants.safeAreaLayoutTop)
                const.centerX.equalTo(scrollView.snp.centerX)
                const.width.equalTo(scrollView.snp.width).multipliedBy(0.95)
                const.bottom.equalTo(scrollView.snp.bottom)
                const.height.equalTo(estimateSize.height)
            }
        } else {
            contentText.snp.remakeConstraints { (const) in
                const.top.equalTo(imageView.snp.bottom)
                const.centerX.equalTo(scrollView.snp.centerX)
                const.width.equalTo(scrollView.snp.width).multipliedBy(0.95)
                
                const.bottom.equalTo(scrollView.snp.bottom)
                const.height.equalTo(estimateSize.height)
            }
        }
        
    }

 

transition 효과가 없는 appstore 틀이 완성 됐다.

 

 

  • ContentViewcontroller

contentVC는 view - contentView(  ScrollView - 여러 View들 )의 순으로 구성 되어 있다.

scrollView가 view를 감싸고 있고 하단의 textView가 스크롤 될 때

여기서 부터 살짝 이해가 안됐던 파트이다.

 

scrollView가 스크롤 되는 것인가? textView가 스크롤 되는 것 인가?

위로 드래그 할때 마지막 bouncing을 보니 textView일듯 싶은데 어째서 위의 이미지 뷰까지 같이 올라가는 거지?

 

------------------------------------------------------------------------------

첫번째 시도

contentView - imageView

                     ㄴ textView

실패했다. textView 안의 존재하는 scrollView를 다루기가 힘들다.

-------------------------------------------------------------------------------

두번째 시도

contentView - imageView

                     ㄴ scrollView - textView

scrollView의 contentOffset의 y값에 따라 imageView의 위치를 조정 해주려다 실패

-------------------------------------------------------------------------------

마지막 시도

contentView - scrollView - imageView

                                         ㄴ textView

성공

-------------------------------------------------------------------------------

 

scrollViewDelegate를 채택하여 scrollView의 contentOffset을 이용한다.

scrollView의 imageView를 contentOffset.y가 0보다 작을 때, 즉 화면을 아래로 당길 때 고정시켜주고 이외에는 scrollView의 top에 종속 시켜준다. 

scrollView의 indicator contentInsets 또한 textView의 top에 종속 된다.

extension AppContentView: UIScrollViewDelegate {
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        
        if scrollView.contentOffset.y < 0 {
            //scrollView.showsVerticalScrollIndicator = false
        }
        
        if scrollView.contentOffset.y < 0 {
            imageView.snp.remakeConstraints { (const) in
                const.top.equalTo(self.snp.top)
                const.centerX.equalToSuperview()
                const.width.equalToSuperview()
                const.height.equalTo(self.snp.width).multipliedBy(0.9 * 1.2).offset(GlobalConstants.safeAreaLayoutTop)
            }
        } else {
            imageView.snp.remakeConstraints { (const) in
                const.top.equalToSuperview()
                const.centerX.equalToSuperview()
                const.width.equalToSuperview()
                const.height.equalTo(self.snp.width).multipliedBy(0.9 * 1.2).offset(GlobalConstants.safeAreaLayoutTop)
            }
        }
    }
}

 

 

 

 

 

 

포인트

- appContentView의 상황별 레이아웃 분기

- appcontentVC에서 scrollView와 textView의 스크롤