iOS) Appstore Transition 따라하기(1) - 기본 구성
우리가 알게 모르게 익숙해져 있는 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의 스크롤