포켓몬 도감 앱의 주요 구현 사항은 UICollectionView
, REST API
, RxSwift
다. 그리고 이 내용들을 MVVM
아키텍처에 담아야 한다. 가이드에는 Model
과 ViewModel
부터 구현하도록 되어있지만, 나는 View
부터 구현하였다.
MainViewController
구현
UIImageView
: 상단 중앙에 포켓몬 볼 이미지 구성UICollectionView
: 세로 스크롤. 한 줄에 3개의 포켓몬 이미지로 구성된 그리드
우선 컨테이너 뷰인 MainView
객체를 생성한 뒤 MainViewController
의 safeArea
에 채워주었다.
class MainViewController: UIViewController {
private lazy var containerView: MainView = .init()
override func viewDidLoad() {
super.viewDidLoad()
setupNavigationBar()
addSubviews()
layout()
}
private func setupNavigationBar() {
navigationController?.navigationBar.isHidden = true
}
private func addSubviews() {
view.addSubviews([containerView])
}
private func layout() {
view.backgroundColor = .mainRed
let safeArea = view.safeAreaLayoutGuide
containerView.snp.makeConstraints {
$0.edges.equalTo(safeArea)
}
}
}
MainView
의 구성은 상단 가운데에 있는 포켓몬 볼 이미지
와 포켓몬 이미지 컬렉션 뷰다. 이미지는 화면 너비의 1/3 크기의 정사각형으로 지정하고, 컬렉션 뷰는 내용 없이 우선 영역만 잡아주었다.
class MainView: UIView {
private lazy var pokemonBallImageView: UIImageView = {
let imageView = UIImageView()
imageView.image = .pokemonBall
imageView.contentMode = .scaleAspectFit
return imageView
}()
// collection view : 영역 지정을 위해 초기화만 구현
private lazy var pokemonCollectionView: UICollectionView = {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout())
collectionView.backgroundColor = .darkRed
return collectionView
}()
// ... //
}
private func layout() {
pokemonBallImageView.snp.makeConstraints {
$0.centerX.equalToSuperview()
$0.top.equalToSuperview().offset(10)
$0.width.equalToSuperview().dividedBy(3) // 화면 너비의 1/3 크기로 지정
$0.height.equalTo(pokemonBallImageView.snp.width) // 높이를 너비와 같게 하여 정사각형 영역으로 구현
}
pokemonCollectionView.snp.makeConstraints {
$0.top.equalTo(pokemonBallImageView.snp.bottom).offset(10)
$0.horizontalEdges.bottom.equalToSuperview()
}
}
포켓몬 볼 이미지 뷰 영역과 컬렉션 뷰 영역의 레이아웃이 잘 적용된 모습
지금까지는 UICollectionViewFlowLayout
을 통해 컬렉션 뷰의 레이아웃을 구성했지만, 이번에는 CompositionalLayout
을 사용해보려 한다. 컴포지셔널 레이아웃은 구성 요소의 계층 별 레이아웃을 지정해주어야 한다.
여기서 기존에 없던 group
이라는 개념이 등장한다. 우선 각 셀을 item
이라고 생각하면 쉽다. item
이 모여 group
을 이루고, group
이 모여 section
을 이룬다. section
은 header
나 footer
같은 supplementary view
를 가질 수 있는 단위다.
group
과 section
의 차이가 뭘까 헷갈릴 수 있는데, 나도 구현을 하면서 실감한 개념이다. vertical
스크롤을 가진 컬렉션 뷰의 경우 하나의 행을 이루는 단위, horizontal
스크롤을 가질 경우에는 하나의 열을 이루는 단위라고 생각하면 쉽다.
코드로 CompositionalLayout
을 구현하면 아래와 같다.
private var collectionViewLayout: UICollectionViewLayout {
// 셀 하나의 크기 : 너비는 컬렉션 뷰 너비의 1/3, 높이는 너비와 같게 지정
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/3), heightDimension: .fractionalWidth(1/3))
// 정의한 itemSize를 갖는 item 선언
let item = NSCollectionLayoutItem(layoutSize: itemSize)
// item 영역의 구성요소(이미지 뷰)에 inset 지정
item.contentInsets = .init(top: 8, leading: 8, bottom: 8, trailing: 8)
// 한 행의 크기 : 너비는 컬렉션 뷰 너비와 같게, 높이는 너비의 1/3 만큼으로(item 너비=높이와 같게) 지정
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalWidth(1/3))
// 정의한 groupSize를 갖는 group 선언하고 구성하는 item 개수와 방향 지정
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3)
// 정의한 group으로 이루어진 section 선언
let section = NSCollectionLayoutSection(group: group)
// 해당 section을 갖는 layout 반환
return UICollectionViewCompositionalLayout(section: section)
}
MainView
에 선언했던 pokemonCollectionView
에 이렇게 정의한 레이아웃을 적용해주고, 이미지 뷰로 꽉 찬 PokemonCell
을 정의한 뒤 register
해주었다. 나중에 API 통신으로 불러온 포켓몬 이미지들을 적용해야 하지만, 임시로 포켓몬 볼 이미지를 넣고 레이아웃이 잘 적용되었는지 확인해보았다.
한 행에 3개의 이미지가 들어간 그리드 형태로 컬렉션 뷰 레이아웃이 잘 적용된 모습
UICollectionViewCell
을 탭했을 때 화면 전환
셀을 탭 했을 때DetailViewController
로 이동하도록 구현
각 셀이 포켓몬 정보를 갖고 있다는 전제 하에, 해당 포켓몬에 대한 디테일이 DetailView
에 그려지며 이동해야 하지만 나는 아직 데이터 fetch
와 bind
를 구현하지 않았기 때문에 그냥 이동 동작만 구현했다. UICollectionViewDelegate
를 사용하여 구현했는데, 컨테이너 뷰 객체에 채택시키지 않고 MainViewController
에 채택하여 구현했다.
class MainView: UIView {
// ... //
private lazy var pokemonCollectionView: UICollectionView = {
// ... //
}()
// collection view의 delegate를 외부(controller)에서 할당
func setDelegate(_ delegate: UICollectionViewDelegate) {
pokemonCollectionView.delegate = delegate
}
}
class MainViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// 컨테이너 뷰의 컬렉션 뷰 delegate에 controller가 self를 할당
containerView.setDelegate(self)
}
}
extension MainViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let vc = DetailViewController()
navigationController?.pushViewController(vc, animated: true)
}
}
네비게이션 바의 경우, MainView
에는 없고 DetailView
에는(Back
버튼) 있다. 이 때 네비게이션 바의 isHidden
값은 컨트롤러마다 viewDidLoad
에 설정을 해주어도 이동 직전 화면의 설정에 그대로 영향을 받는 것을 경험했다.
MainViewController
에navigationBar.isHidden = true
를 했지만navigationBar.isHidden = false
인DetailViewController
에 갔다오자 다시 네비게이션 바 영역이 생긴 모습
그래서 navigationBar.isHidden
설정 코드를 양쪽 컨트롤러의 viewWillAppear
에 옮겨주었다.
DetailViewController
구현
MainView
의 컬렉션 뷰 셀을 누르면 해당 포켓몬의 상세 정보 화면(DetailView
)으로 이동
하지만 아직 데이터fetch
구현 전이므로UI
작업만 함.
처음에 그냥 단순하게 DetailView
라는 DetailViewController
의 컨테이너 뷰에 컴포넌트들을 넣고 top
에 대한 제약조건을 넣으면서 컴포넌트 간 간격을 잡았었는데, 컴포넌트의 박스 뷰가 컨테이너 뷰의 가운데에 오도록 구현하고 싶어서 subview
로 스택 뷰를 한 번 더 정의하고, 그 안에 컴포넌트들을 넣었다. 그리고 네비게이션 바의 Back
버튼이 파란색인 게 별로 안이뻐보여서 tintColor
를 흰색으로 지정했다.
class DetailStackView: UIStackView {
private lazy var pokemonImageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
return imageView
}()
private lazy var nameLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 24, weight: .bold)
label.textColor = .white
label.textAlignment = .center
return label
}()
private lazy var typeLabel = detailLabel()
private lazy var heightLabel = detailLabel()
private lazy var weightLabel = detailLabel()
// 재사용 label
private func detailLabel() -> UILabel {
let label = UILabel()
label.font = .systemFont(ofSize: 18)
label.textColor = .white
label.textAlignment = .center
return label
}
// ... //
private func layout() {
backgroundColor = .darkRed
layer.cornerRadius = 15
axis = .vertical
spacing = 8
distribution = .fill
pokemonImageView.snp.makeConstraints {
$0.height.equalToSuperview().dividedBy(2)
}
}
}
임시로 포켓몬 볼 이미지 + 고라파덕의 정보를 넣고 구현한 DetailView UI