private lazy var collectionView: UICollectionView = {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout())
collectionView.delegate = self
collectionView.dataSource = self
collectionView.register(StoryCollectionViewCell.self, forCellWithReuseIdentifier: StoryCollectionViewCell.identifier)
collectionView.register(PortraitCollectionViewCell.self, forCellWithReuseIdentifier: PortraitCollectionViewCell.identifier)
collectionView.register(LandscapeCollectionViewCell.self, forCellWithReuseIdentifier: LandscapeCollectionViewCell.identifier)
collectionView.register(CollectionViewHeaderReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: CollectionViewHeaderReusableView.identifier)
return collectionView
}()
lazy var
을 통해 선언 이후 델리게이트와 데이터 소스에 self
를 줄 수 있음func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let sections = viewModel.pageData.value
switch sections[indexPath.section] {
case .stories(let models):
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: StoryCollectionViewCell.identifier, for: indexPath) as? StoryCollectionViewCell else { fatalError() }
cell.configure(with: models[indexPath.row])
return cell
case .popular(let models):
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PortraitCollectionViewCell.identifier, for: indexPath) as? PortraitCollectionViewCell else { fatalError() }
cell.configire(with: models[indexPath.row])
return cell
case .comingSoon(let models):
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: LandscapeCollectionViewCell.identifier, for: indexPath) as? LandscapeCollectionViewCell else { fatalError() }
cell.configire(with: models[indexPath.row])
return cell
}
}
sections
을 통해 어떤 종류의 섹션인지 확인 가능row
를 통해 해당 섹션 내 어떤 아이템을 넣어야 하는지 확인guard let
바인딩을 통해 주어진 섹션에 알맞은 셀 클래스를 얻어낸 뒤 커스텀 함수를 사용func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
switch kind {
case UICollectionView.elementKindSectionHeader:
guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: CollectionViewHeaderReusableView.identifier, for: indexPath) as? CollectionViewHeaderReusableView else { fatalError() }
header.configure(with: viewModel.pageData.value[indexPath.section].title)
return header
default: return UICollectionReusableView()
}
}
private func createLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { [weak self] sectionIndex, layoutEnvironment in
guard let self = self else { return nil }
let section = self.viewModel.pageData.value[sectionIndex]
switch section {
case .stories:
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .absolute(70), heightDimension: .absolute(70)), subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuous
section.interGroupSpacing = 10
section.contentInsets = .init(top: 0, leading: 10, bottom: 30, trailing: 10)
section.boundarySupplementaryItems = [self.createSupplementaryHeaderItem()]
section.supplementaryContentInsetsReference = .layoutMargins
return section
case .popular:
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(0.9), heightDimension: .fractionalHeight(0.6)), subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .groupPaging
section.interGroupSpacing = 10
section.contentInsets = .init(top: 0, leading: 10, bottom: 30, trailing: 10)
section.boundarySupplementaryItems = [self.createSupplementaryHeaderItem()]
section.supplementaryContentInsetsReference = .layoutMargins
return section
case .comingSoon:
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .absolute(170), heightDimension: .absolute(80)), subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .groupPaging
section.interGroupSpacing = 10
section.contentInsets = .init(top: 0, leading: 10, bottom: 0, trailing: 10)
section.boundarySupplementaryItems = [self.createSupplementaryHeaderItem()]
section.supplementaryContentInsetsReference = .layoutMargins
return section
}
}
}
supplementaryContentInsetsReference
는 iOS 16부터 도입된 프로퍼티로 해당 아이템이 어떤 위치에 존재할 지 결정 가능private func createSupplementaryHeaderItem() -> NSCollectionLayoutBoundarySupplementaryItem {
return NSCollectionLayoutBoundarySupplementaryItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .estimated(50)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
}
import UIKit
import Combine
class CollectionViewController: UIViewController {
private lazy var collectionView: UICollectionView = {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout())
collectionView.delegate = self
collectionView.dataSource = self
collectionView.register(StoryCollectionViewCell.self, forCellWithReuseIdentifier: StoryCollectionViewCell.identifier)
collectionView.register(PortraitCollectionViewCell.self, forCellWithReuseIdentifier: PortraitCollectionViewCell.identifier)
collectionView.register(LandscapeCollectionViewCell.self, forCellWithReuseIdentifier: LandscapeCollectionViewCell.identifier)
collectionView.register(CollectionViewHeaderReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: CollectionViewHeaderReusableView.identifier)
return collectionView
}()
private let viewModel = CollectionViewModel()
private var cancellables = Set<AnyCancellable>()
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
collectionView.frame = view.bounds
}
override func viewDidLoad() {
super.viewDidLoad()
setUI()
bind()
}
private func setUI() {
view.backgroundColor = .systemBackground
view.addSubview(collectionView)
}
private func bind() {
viewModel
.pageData
.sink { [weak self] _ in
self?.collectionView.reloadData()
}
.store(in: &cancellables)
}
private func createLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { [weak self] sectionIndex, layoutEnvironment in
guard let self = self else { return nil }
let section = self.viewModel.pageData.value[sectionIndex]
switch section {
case .stories:
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .absolute(70), heightDimension: .absolute(70)), subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuous
section.interGroupSpacing = 10
section.contentInsets = .init(top: 0, leading: 10, bottom: 30, trailing: 10)
section.boundarySupplementaryItems = [self.createSupplementaryHeaderItem()]
section.supplementaryContentInsetsReference = .layoutMargins
return section
case .popular:
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(0.9), heightDimension: .fractionalHeight(0.6)), subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .groupPaging
section.interGroupSpacing = 10
section.contentInsets = .init(top: 0, leading: 10, bottom: 30, trailing: 10)
section.boundarySupplementaryItems = [self.createSupplementaryHeaderItem()]
section.supplementaryContentInsetsReference = .layoutMargins
return section
case .comingSoon:
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .absolute(170), heightDimension: .absolute(80)), subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .groupPaging
section.interGroupSpacing = 10
section.contentInsets = .init(top: 0, leading: 10, bottom: 0, trailing: 10)
section.boundarySupplementaryItems = [self.createSupplementaryHeaderItem()]
section.supplementaryContentInsetsReference = .layoutMargins
return section
}
}
}
private func createSupplementaryHeaderItem() -> NSCollectionLayoutBoundarySupplementaryItem {
return NSCollectionLayoutBoundarySupplementaryItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .estimated(50)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
}
}
extension CollectionViewController: UICollectionViewDelegate {
}
extension CollectionViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return viewModel.pageData.value[section].count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let sections = viewModel.pageData.value
switch sections[indexPath.section] {
case .stories(let models):
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: StoryCollectionViewCell.identifier, for: indexPath) as? StoryCollectionViewCell else { fatalError() }
cell.configure(with: models[indexPath.row])
return cell
case .popular(let models):
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PortraitCollectionViewCell.identifier, for: indexPath) as? PortraitCollectionViewCell else { fatalError() }
cell.configire(with: models[indexPath.row])
return cell
case .comingSoon(let models):
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: LandscapeCollectionViewCell.identifier, for: indexPath) as? LandscapeCollectionViewCell else { fatalError() }
cell.configire(with: models[indexPath.row])
return cell
}
}
func numberOfSections(in collectionView: UICollectionView) -> Int {
return viewModel.pageData.value.count
}
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
switch kind {
case UICollectionView.elementKindSectionHeader:
guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: CollectionViewHeaderReusableView.identifier, for: indexPath) as? CollectionViewHeaderReusableView else { fatalError() }
header.configure(with: viewModel.pageData.value[indexPath.section].title)
return header
default: return UICollectionReusableView()
}
}
}
switch case
로 받은 뒤 어떤 종류의 셀을 적용할지 결정 가능import UIKit
import Combine
class CollectionViewModel {
let pageData: CurrentValueSubject<[PhotoSection], Never> = .init([])
private var cancellables = Set<AnyCancellable>()
init() {
fetchData()
}
// fetchData() -> return mock data
}
import Foundation
struct PhotoModel: Hashable, Codable {
var id: String {
return UUID().uuidString
}
let name: String
let imageURLString: String
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
import Foundation
enum PhotoSection {
case stories([PhotoModel])
case popular([PhotoModel])
case comingSoon([PhotoModel])
var models: [PhotoModel] {
switch self {
case .stories(let models), .comingSoon(let models), .popular(let models): return models
}
}
var count: Int {
return models.count
}
var title: String {
switch self {
case .stories: return "Stories"
case .popular: return "Popular"
case .comingSoon: return "Coming Soon"
}
}
}
import UIKit
class CollectionViewHeaderReusableView: UICollectionReusableView {
static let identifier = "CollectionViewHeaderReusableView"
private let textLabel: UILabel = {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .headline)
label.textColor = .label
label.textAlignment = .left
label.numberOfLines = 0
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
setUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
textLabel.text = nil
}
override func layoutSubviews() {
super.layoutSubviews()
textLabel.frame = bounds
}
private func setUI() {
addSubview(textLabel)
}
func configure(with title: String) {
textLabel.text = title
}
}
import UIKit
class StoryCollectionViewCell: UICollectionViewCell {
static let identifier = "StoryCollectionViewCell"
private let imageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.layer.masksToBounds = true
return imageView
}()
override init(frame: CGRect) {
super.init(frame: frame)
setUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
imageView.frame = contentView.bounds
imageView.layer.cornerRadius = imageView.frame.height / 2
}
override func prepareForReuse() {
super.prepareForReuse()
imageView.image = nil
}
private func setUI() {
contentView.addSubview(imageView)
}
func configure(with model: PhotoModel) {
guard let url = URL(string: model.imageURLString) else { return }
URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
guard
let data = data,
let image = UIImage(data: data),
let response = response as? HTTPURLResponse,
response.statusCode >= 200 && response.statusCode < 400,
error == nil else {
return
}
DispatchQueue.main.async { [weak self] in
self?.imageView.image = image
}
}
.resume()
}
}
import UIKit
class PortraitCollectionViewCell: UICollectionViewCell {
static let identifier = "PortraitCollectionViewCell"
private let imageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = 12
return imageView
}()
private let textLabel: UILabel = {
let label = UILabel()
label.textColor = .systemBackground
label.textAlignment = .center
label.numberOfLines = 0
label.font = .preferredFont(forTextStyle: .body)
label.backgroundColor = .label
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
setUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
imageView.frame = contentView.bounds
}
override func prepareForReuse() {
super.prepareForReuse()
imageView.image = nil
textLabel.text = nil
}
private func setUI() {
contentView.addSubview(imageView)
imageView.addSubview(textLabel)
textLabel.translatesAutoresizingMaskIntoConstraints = false
let textLabelConstrains = [
textLabel.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
textLabel.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
textLabel.bottomAnchor.constraint(equalTo: imageView.bottomAnchor)
]
NSLayoutConstraint.activate(textLabelConstrains)
}
func configire(with model: PhotoModel) {
textLabel.text = model.name
guard let url = URL(string: model.imageURLString) else { return }
URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
guard
let data = data,
let image = UIImage(data: data),
let response = response as? HTTPURLResponse,
response.statusCode >= 200 && response.statusCode < 400,
error == nil else {
return
}
DispatchQueue.main.async { [weak self] in
self?.imageView.image = image
}
}
.resume()
}
}
import UIKit
class LandscapeCollectionViewCell: UICollectionViewCell {
static let identifier = "LandscapeCollectionViewCell"
private let imageView: UIImageView = {
let imageView = UIImageView()
imageView.clipsToBounds = true
imageView.contentMode = .scaleAspectFill
return imageView
}()
private let textLabel: UILabel = {
let label = UILabel()
label.backgroundColor = .label
label.textColor = .systemBackground
label.textAlignment = .center
label.numberOfLines = 0
label.clipsToBounds = true
label.font = .preferredFont(forTextStyle: .body)
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
setUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
imageView.image = nil
textLabel.text = nil
}
override func layoutSubviews() {
super.layoutSubviews()
let height = contentView.frame.size.height
let imageWidth = contentView.frame.size.width / 3
let textWidth = contentView.frame.size.width - imageWidth
imageView.frame = CGRect(origin: .zero, size: .init(width: imageWidth, height: height))
textLabel.frame = CGRect(origin: .init(x: imageWidth, y: 0), size: .init(width: textWidth, height: height))
}
private func setUI() {
contentView.layer.cornerRadius = 12
contentView.layer.masksToBounds = true
contentView.addSubview(imageView)
contentView.addSubview(textLabel)
}
func configire(with model: PhotoModel) {
textLabel.text = model.name
guard let url = URL(string: model.imageURLString) else { return }
URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
guard
let data = data,
let image = UIImage(data: data),
let response = response as? HTTPURLResponse,
response.statusCode >= 200 && response.statusCode < 400,
error == nil else {
return
}
DispatchQueue.main.async { [weak self] in
self?.imageView.image = image
}
}
.resume()
}
}
섹션 별로 다양하고 조화롭게 사용할 수 있도록 보다 익숙해지자!