iOS) UICollectionView custom layout에 대한 고찰- 1 (UICollectionViewFlowLayout, UICollectionViewLayout)
이전 글에 이어서 오늘은 iOS 13부터 사용 가능한 UICollectionViewCompositionalLayout에 대해서 다뤄보려 합니다.
이 레이아웃을 UIKit으로 구현하려면 어떻게 해야 할까요? CollectionView의 중첩? 꽤 어려울 것입니다. 하지만 UICollectionViewCompositionalLayout를 쓰면 꽤나 간단해집니다.
UICollectionViewCompositionalLayout
UICollectionViewLayout을 상속받습니다. 이름처럼 여러 레이아웃을 결합하기 위해 사용하는 레이아웃입니다. 이를 위해 3가지의 컴포넌트로 구분하여 레이아웃을 구성합니다.
Section (NSCollectionLayoutSection)
NSCollectionLayoutSection을 이용합니다. Group을 담는 Container입니다. Collection View는 하나 또는 여러 개의 Section을 가질 수 있습니다. Section은 Layout의 각각의 영역을 나타냅니다. Section은 NSCollectionLayoutGroup에 의해 결정됩니다. 각 Section은 고유의 배경, Header, Footer를 가질 수 있습니다.
Group (NSCollectionLayoutGroup)
NSCollectionLayoutGroup을 이용합니다. Item을 담는 Container입니다. NSCollectionLayoutItem을 상속받았기 때문에 LayoutItem과 유사하게 동작합니다. Item을 특정 Path에 따라 배치하는 역할을 합니다. Group 자체는 레이아웃만 배치하고 렌더링은 하지 않습니다. 이 Group은 Section의 init(group:)에 주입됩니다. Group은 NSCollectionLayoutDimension을 이용하여 크기를 설정할 수 있습니다. 이에 대해선 아래에 설명을 적겠습니다.
Item (NSCollectionLayoutItem)
NSCollectionLayoutItem을 이용합니다. Collection View의 가장 기본 컴포넌트입니다. Item은 크기, 개별 content의 size, space, arragnge를 어떻게 할지에 대한 blueprint입니다. 일반적으로 Item은 Cell이지만 Headers, Footers, Decorations와 같은 Supplementary View도 될 수 있습니다. 마찬가지로 NSCollectionLayoutDimension로 크기를 결정합니다. 이 Item은 Group에 주입됩니다.
NSCollectionLayoutDimension
CollectinView의 Item의 크기를 결정합니다. 크기를 정하는 법은 3가지가 있습니다.
absoulte
let absoluteSize = NSCollectionLayoutSize(widthDimension: .absolute(44),
heightDimension: .absolute(44))
말 그대로 절대 크기입니다. 항상 고정된 크기로 나타납니다.
estimated
let estimatedSize = NSCollectionLayoutSize(widthDimension: .estimated(200),
heightDimension: .estimated(100))
런타임에 크기가 변할 가능성이 있는 경우(시스템의 글꼴 크기 변경과 같은) estimated를 사용합니다. 이는 시스템이 예상 크기를 기반으로 실제 크기를 계산합니다.
fractional
let fractionalSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.2),
heightDimension: .fractionalHeight(0.2))
이 fractional이 굉장히 쓸만합니다. SwiftUI에서 geomtryGeometryReader와 유사하게, 현재 자신이 속한 컨테이너의 크기를 기반으로 비율로써 자신의 크기를 정합니다. 0.0~1.0 사이의 CGFloat값을 넣을 수 있습니다.
실습에 앞서...
UIKit의 View여도 SwiftUI의 Preview를 이용해 빠르게 레이아웃을 확인할 수 있습니다.
final class MyController: UICollectionViewController {
//.. My code
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Container().edgesIgnoringSafeArea(.all)
}
struct Container: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
return UINavigationController(rootViewController: MyController())
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
}
typealias UIViewControllerType = UIViewController
}
}
실습
실습 대상은 바로 이 프로젝트입니다. NAVER VIBE를 클론 했었는데, 이 프로젝트는 SwiftUI를 이용해 구현하였습니다. 이번엔 레이아웃을 UIKit의 CompostionaLayout을 이용해 구현해보겠습니다. 크게 헤더와 3가지 영역만 구현해보겠습니다. 이렇게 사용하는구나 정도로 참고하시면 좋을 것 같습니다.
헤더
final class Header: UICollectionReusableView {
let label = UILabel()
private let stackView = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
label.text = "Categories"
addSubview(stackView)
stackView.addArrangedSubview(label)
let button = UIButton(type: .system)
button.setTitle("더보기", for: .normal)
button.setContentHuggingPriority(.defaultHigh, for: .horizontal)
stackView.addArrangedSubview(button)
}
override func layoutSubviews() {
super.layoutSubviews()
stackView.frame = bounds
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
큰 아이템 섹션
섹션 모두 스크롤이 가능함을 알리기 위해 다음 Item의 끄트머리가 보여야 합니다. 이 조건은 이후의 모든 섹션에 적용됩니다.
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(1)))
item.contentInsets = .init(top: 0, leading: 5, bottom: 16, trailing: 5)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(0.9), heightDimension: .estimated(200)), subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.boundarySupplementaryItems = [.init(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(50)), elementKind: catgoryHeaderId, alignment: .topLeading)]
section.orthogonalScrollingBehavior = .groupPaging
section.contentInsets = .init(top: 0, leading: 16, bottom: 0, trailing: 16)
return section
일반 아이템 섹션
크기만 수정해주면 쉽게 구현할 수 있습니다. Group Paging을 사용하기 위해 Item이 아닌 Group의 크기를 줄여줍니다.
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(1)))
item.contentInsets = .init(top: 0, leading: 5, bottom: 16, trailing: 5)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(0.45), heightDimension: .estimated(200)), subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.boundarySupplementaryItems = [.init(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(50)), elementKind: catgoryHeaderId, alignment: .topLeading)]
section.orthogonalScrollingBehavior = .groupPaging
section.contentInsets = .init(top: 0, leading: 16, bottom: 0, trailing: 16)
return section
노래 목록 섹션
노래목록 섹션은 한 섹션의 5개의 노래 목록이 있습니다. horizontal을 vertical로 바꿔주고 count를 5로 변경합니다.
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
item.contentInsets = .init(top: 0, leading: 5, bottom: 16, trailing: 5)
let group = NSCollectionLayoutGroup.vertical(layoutSize: .init(widthDimension: .fractionalWidth(0.9), heightDimension: .estimated(300)), subitem: item, count: 5)
let section = NSCollectionLayoutSection(group: group)
section.boundarySupplementaryItems = [.init(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(50)), elementKind: catgoryHeaderId, alignment: .topLeading)]
section.orthogonalScrollingBehavior = .groupPaging
section.contentInsets = .init(top: 0, leading: 16, bottom: 0, trailing: 16)
return section
조합
static func createLayout() -> UICollectionViewCompositionalLayout {
return UICollectionViewCompositionalLayout { (sectionNumber, env) -> NSCollectionLayoutSection? in
if sectionNumber == 0 {
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(1)))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(0.9), heightDimension: .estimated(200)), subitems: [item])
group.contentInsets = .init(top: 0, leading: 5, bottom: 16, trailing: 5)
let section = NSCollectionLayoutSection(group: group)
section.boundarySupplementaryItems = [.init(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(50)), elementKind: catgoryHeaderId, alignment: .topLeading)]
section.orthogonalScrollingBehavior = .groupPaging
section.contentInsets = .init(top: 0, leading: 16, bottom: 0, trailing: 16)
return section
} else if sectionNumber == 1 {
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(1)))
item.contentInsets = .init(top: 0, leading: 5, bottom: 16, trailing: 5)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(0.45), heightDimension: .estimated(200)), subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.boundarySupplementaryItems = [.init(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(50)), elementKind: catgoryHeaderId, alignment: .topLeading)]
section.orthogonalScrollingBehavior = .groupPaging
section.contentInsets = .init(top: 0, leading: 16, bottom: 0, trailing: 16)
return section
} else {
let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
item.contentInsets = .init(top: 0, leading: 5, bottom: 16, trailing: 5)
let group = NSCollectionLayoutGroup.vertical(layoutSize: .init(widthDimension: .fractionalWidth(0.9), heightDimension: .estimated(300)), subitem: item, count: 5)
let section = NSCollectionLayoutSection(group: group)
section.boundarySupplementaryItems = [.init(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(50)), elementKind: catgoryHeaderId, alignment: .topLeading)]
section.orthogonalScrollingBehavior = .groupPaging
section.contentInsets = .init(top: 0, leading: 16, bottom: 0, trailing: 16)
return section
}
}
}
이제 3가지 섹션을 합쳐줍니다. SectionNumber에 따라 분기 처리를 하면 됩니다.
고찰
이전에 SwiftUI로 했던 프로젝트를 UIKit으로 구현하려면 어려울 수 있을 거라 생각했는데, iOS 13부턴 CompositionalLayout을 쓰면 생각보다 편하게 구현할 수 있을 것 같습니다. 이 외에도 다양한 레이아웃 구현이 가능한데, developer.apple.com/documentation/uikit/views_and_controls/collection_views/implementing_modern_collection_views
이곳을 참고하시면 좋을 것 같습니다.
References
www.raywenderlich.com/5436806-modern-collection-views-with-compositional-layouts
www.youtube.com/watch?v=y1uXXVUu43o
developer.apple.com/documentation/uikit/uicollectionlayoutlistconfiguration
developer.apple.com/documentation/uikit/nscollectionlayoutsection
developer.apple.com/documentation/uikit/nscollectionlayoutgroup