안녕하세요. 후르륵짭짭입니다.
딱히 한 것도 없는데 6월이 끝나가네요... ㅠ ㅠ
시간이 참 빠르게 흘러가는 것 같습니다.
그래서 최근에는 건강 해지기 위해 어릴 때 한 운동인 수영을 다시 시작 했습니다.
그래서 주말에는 동내 수영장에서 자유 수영도 했네요.
또 회사에 아시는 것도 많고 밝은 인성을 가지신 분의 조언으로 회사 근처 수영 학원도 끊었습니다 ^ ^.
이번에는 WWDC 2019년,,, 4년 전에 나온 새로운 CollectionView 기술인 CompositionalLayout에 대해 정리하려고 합니다.
사내에서 새롭게 UI를 꾸미면서 기존의 CollectionView Layout 방식에서 CompositionalLayout으로 변경하게 됐습니다.
그래서 알게됐는데,,, 반성합니다 ㅠㅠ . ( 생각 보다 내용이 많아 이것도 차근히 정리하려고 합니다. )
정말 다양한 기법들이 있고 저도 사용하고 싶은 분야에 이 기술을 사용하려고 해서 작성하게 됐습니다.
** CompositionalLayout **
일단 CompositionalLayout이라는 것은 Layout 객체인데, 적응형으로 유연하고 잘 정리해주는 그런 Object 입니다.
기존에 CollectionView의 Layout을 그려주기 위해서는 FlowLayoutDelegate를 받아서 그림을 그려줬습니다.
이게 아주 헬인게,,,
고정된 가로 세로의 Layout 이라면 괜찮은데, 실기기가 가로일 때, 세로일 때 모양이 다 달라진다고 한다면 적용해줘야할 게 많습니다.
(막,, 전체 가로 길이에서 4개로 나누고 Inset이 있다면 Inset도 빼주고,,, 아주 복잡)
그런데 2019년에,,, 이런 것을 아주 자동으로 해주는 좋은 친구가 생긴겁니다.
위의 이미지 처럼 CompositionalLayout은 3개의 구역(Section , Group, Item)으로 나눠져있습니다.
Item은 하나의 Cell이라고 생각하면 편합니다.
그리고 Group은 Cell의 바구니(?) , 뭉텅이(?) 그런거로 생각하면 좋고
Section은 Ground의 뭉텅이라 생각하면 됩니다.
이들의 사이즈는 그럼 어떻게 결정 되는 것이냐! 싶을건데요.
Group과 Item은 NSCollectionLayoutSize 으로 사이즈를 정하게 되는데,
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(200))
let layoutItem = NSCollectionLayoutItem(layoutSize: itemSize)
let layoutGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(300))
let layoutGroup = NSCollectionLayoutGroup.vertical(layoutSize: layoutGroupSize, subitems: [layoutItem])
위와 같이 했다면 Group의 300이라는 공간안에서 Item 200 만큼 채웁니다.
이때 Group하나에 하니의 Item 밖에 못 들어가니 요렇게 Item은 하나 들어가게 됩니다.
만약에 Item Size를 100으로 했으면 3개가 들어가겠죠?
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(100))
let layoutItem = NSCollectionLayoutItem(layoutSize: itemSize)
let layoutGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(310))//Group의 간격 때문에 10을 추가함
let layoutGroup = NSCollectionLayoutGroup.vertical(layoutSize: layoutGroupSize, subitems: [layoutItem])
layoutGroup.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 15, bottom: 0, trailing: 15)//Group의 차이를 보이기 위해서 10을 줬습니다.
위에서는 heightDimension을 보면 Dimension을 absolute로 절대값을 준 상황인데,
보통 CompositionLayout을 사용할 때는 fractional을 통해서 비율로 줄 수 있습니다.
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(0.3))
let layoutItem = NSCollectionLayoutItem(layoutSize: itemSize)
let layoutGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
let layoutGroup = NSCollectionLayoutGroup.vertical(layoutSize: layoutGroupSize, subitems: [layoutItem])
이렇게 frational을 1.0으로 준다면 CollectionView의 크기를 1로 보게 됩니다.
그럼 위 코드를 통해 Group은 CollectionView의 전체 크기가 되는 것이고 Item은 해당 Group의 0.3 으로 가지게 되니
3개가 비율에 맞게 들어가게 되는 겁니다.
그래서 이렇게 Item과 Group을 생성한 후에 Section에 Gruop을 넣어줍니다.
let section = NSCollectionLayoutSection(group: layoutGroup)
section.contentInsets = .init(top: 20, leading: 0, bottom: 20, trailing: 0)
그렇게 되면 Section은 Group의 크기를 기준으로 Section의 크기를 만들게 됩니다.
그리고 이 NSCollectionLayoutSection을 Return 해주면 됩니다.
extension NSCollectionLayoutSection {
static func listSection(withEstimatedHeight estimatedHeight: CGFloat = 100) -> NSCollectionLayoutSection {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
let layoutItem = NSCollectionLayoutItem(layoutSize: itemSize)
layoutItem.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 15, bottom: 10, trailing: 15)
let layoutGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(estimatedHeight))
let layoutGroup = NSCollectionLayoutGroup.vertical(layoutSize: layoutGroupSize, subitems: [layoutItem])
layoutGroup.interItemSpacing = .fixed(10)
let section = NSCollectionLayoutSection(group: layoutGroup)
section.contentInsets = .init(top: 20, leading: 0, bottom: 20, trailing: 0)
return section
}
}
그리고 이 LayoutSection을 CompositionalLayout 객체에 넣어줘서 반환 해줍니다.
extension UICollectionViewLayout {
static func listLayout() -> UICollectionViewCompositionalLayout {
let layout = UICollectionViewCompositionalLayout(section: .listSection())
layout.register(SectionBackgroundDecorationView.self, forDecorationViewOfKind: String(describing: SectionBackgroundDecorationView.self))
return layout
}
}
마지막으로 이 CompositionalLayout을 CollectionView에 넣어줍니다.
class CompositionalLayoutDiffableViewController : UIViewController {
lazy var mainCollectionView : UICollectionView = {
let collectionView = UICollectionView(frame: .infinite, collectionViewLayout: .listLayout())
collectionView.register(ColorCell.self, forCellWithReuseIdentifier: String(describing: ColorCell.self))
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.backgroundColor = .lightGray
return collectionView
}()
.... 생략 ....
}
이렇게 하면 CompsitionalLayout을 통해 CollectionView의 Layout을 만들 수 있게 됩니다.
여기까지가 Layout을 생성하는 기본적인 방법이라 생각 합니다.
( 이 CompositionalLayot은 다룰게 많기 때문에 추후에 다시 다루도록 하겠습니다. )
** UICollectionViewDataSource VS UICollectionViewDiffableDataSource **
DataSource는 CollectionView, TableView에 보여줄 데이터를 의미합니다.
기존에는 Delegate Pattern으로 DataSource Protocol을 준수하고 해당 메소드들을 구현해줬습니다.
extension CompositionalLayoutViewController : UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return self.colorList.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ColorCell.self), for: indexPath) as? ColorCell else {
return UICollectionViewCell()
}
cell.contentView.backgroundColor = self.colorList[indexPath.item]
return cell
}
}
요렇게 UICollectionViewDataSource를 준수하여 numberOfItemsInSection을 통해서 보여줄 데이터 갯수를 지정해주고
cellForItemAt을 통해서 Cell을 그려주는 역할을 했습니다.
class CompositionalLayoutViewController : UIViewController {
var colorList : [UIColor] {
didSet{
self.mainCollectionView.reloadData()
}
}
lazy var mainCollectionView : UICollectionView = {
let collectionView = UICollectionView(frame: .infinite, collectionViewLayout: .listLayout())
collectionView.register(ColorCell.self, forCellWithReuseIdentifier: String(describing: ColorCell.self))
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.backgroundColor = .darkGray
collectionView.dataSource = self
return collectionView
}()
,,,, 생략 ,,,,
}
그리고 dataSource를 어떤 객체가 대신해서 해줄 것인지 지정해주는 방식이였고
데이터가 반영되었다면 CollectionView.reloadData() 메소드를 호출해서 Refresh 해줘야했습니다.
그런데 최근에는 UICollectionViewDiffableDataSource와 NSDiffableDataSourceSnapshot 객체가 생겼습니다.
이 둘이 무엇인가 하면 DiffableDataSource는 cellForItemAt이라고 할 수 있습니다.
그리고 DiffableDataSourceSnapshot은 더욱 강력해진 numberOfItemsInSection 이라고 할 수 있습니다.
class CompositionalLayoutDiffableViewController : UIViewController {
private lazy var dataSource : UICollectionViewDiffableDataSource<Int, Color> = {
let colorDataSource = UICollectionViewDiffableDataSource<Int, Color>(collectionView: self.mainCollectionView, cellProvider: { collectionView, indexPath, itemIdentifier in
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ColorCell.self), for: indexPath) as? ColorCell else {
return UICollectionViewCell()
}
cell.contentView.backgroundColor = itemIdentifier.color
return cell
})
return colorDataSource
}()
lazy var mainCollectionView : UICollectionView = {
let collectionView = UICollectionView(frame: .infinite, collectionViewLayout: .listLayout())
collectionView.register(ColorCell.self, forCellWithReuseIdentifier: String(describing: ColorCell.self))
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.backgroundColor = .lightGray
return collectionView
}()
,,,, 생략 ,,,,
}
이렇게 보면 CellForItemAt가 생긴게 비슷하죠?
UICollectionViewDiffableDataSource에 CollectionView를 넣어주면 됩니다.
근데 이때 DiffableDataSource는 Genric Type으로 되어 있는데
두개의 GenricType은 Hashable과 Sendable을 준수하고 있어야합니다.
( Hashable이 준수 되어 있지않고 중복된다면 Crash가 발생하더라구요... )
위에 GenricType의 이름을 보니 왼쪽은 Section에 관한 것이고 오른쪽은 Item과 관련된 것으로 보이네요.
그리고 CellProvider도 ItemIdentifierType으로 되어 있네요.
저는 그래서 Generic을 Section 부분은 Int로 Item은 Color 라는 Struct를 사용 했습니다.
struct Color {
let id = UUID()
let color : UIColor
}
extension Color: Hashable , Equatable , Sendable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func ==(lhs: Color, rhs: Color) -> Bool {
return lhs.id == rhs.id
}
}
이렇게 id를 UUID를 통해서 중복 되지 않도록 해줬습니다.
이제 Cell을 그려주는 부분은 완성이 됐습니다.
Data를 넣어주는 NSDiffableDataSourceSnapshot을 보도록 하겠습니다.
UICollectionViewDiffableDataSource와 동일한 보양이네요.
class CompositionalLayoutDiffableViewController : UIViewController {
private lazy var dataSource : UICollectionViewDiffableDataSource<Int, Color> = {
let colorDataSource = UICollectionViewDiffableDataSource<Int, Color>(collectionView: self.mainCollectionView, cellProvider: { collectionView, indexPath, itemIdentifier in
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ColorCell.self), for: indexPath) as? ColorCell else {
return UICollectionViewCell()
}
cell.contentView.backgroundColor = itemIdentifier.color
return cell
})
return colorDataSource
}()
,,,, 생략 ,,,,
init() {
super.init(nibName: nil, bundle: nil)
configureView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setSnapShot(_ snapShot : NSDiffableDataSourceSnapshot<Int, Color>) {
self.dataSource.apply(snapShot)
}
,,,, 생략 ,,,,
}
위에서 생성한 dataSource를 apply 메소드를 통해서 적용해줍니다. (DataSource와 SnapShot이 동일한 타입이어야 합니다.)
이렇게 되면 이제 DataSourceSnapShot이 들어 올 때 마다 reloadData() 가 호출 된다고 보면 됩니다.
그럼 SnapShot은 어떻게 만드는지 보도록 하겠습니다.
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
guard let viewController = uiViewController as? CompositionalLayoutDiffableViewController else {return}
var snapShot = NSDiffableDataSourceSnapshot<Int, Color>()
snapShot.appendSections([0,1])
let colorList = self.colorList.map({Color.init(color: $0)})
let secondColorList = self.colorList[0...5].map({Color.init(color: $0)})
snapShot.appendItems(colorList, toSection: 0)
snapShot.appendItems(secondColorList, toSection: 1)
viewController.setSnapShot(snapShot)
}
위에서 사용한 메소드는 appendSections, appendItems 총 두개 입니다. (더 많은데,,, 오늘은 여기까지 하겠습니다.)
Generic을 Int로 잡았으니 snapshot의 appendSection 또한 Int가 됩니다.
appendSection은 배열로 되어 있는데 이 부분에 원하는 만큼 추가하면 원하는 만큼의 Section이 생성이 됩니다.
그리고 appendItem은 identifiers와 toSection이 존재하는데,
identifiers에 해당 Section에 들어갈 Item을 전달하고 toSection에는 이 Section에 아이템을 전달하는 것을 의미합니다.
** 참고로 Section에 Item을 담을 수 있는 Hashable Object를 만들고 이것을 전달하면 더욱 좋을 듯 싶습니다. **
지금까지 CompositionalLayout에 대해서 기초를 알아봤는데요.
정말 다양하게 적용이 될 듯 합니다.
이제 점점 반응형 API를 제공해주는 것이 좋아 보입니다.
아직 알아야 할게 더 많아서 좀 더 공부해보고 차근차근 정리 하도록 하겠습니다.
이럴 기술이 있다는거 알려주고 적용해주신 분 고마워요!
많이 배워갑니당!
** 참고 사이트 **
apple 공식 compositional Layout :
https://developer.apple.com/documentation/uikit/uicollectionviewcompositionallayout
apple 공식 sampel App :
compositionalLayout에 대한 고찰 :
https://demian-develop.tistory.com/22
https://ios-development.tistory.com/945
동적인 Cell 만들기 :
https://ios-daniel-yang.tistory.com/94
DiffableDataSource :
https://zeddios.tistory.com/1197
https://applecider2020.tistory.com/37
좋은 compositionalLayout Sample :
https://github.com/nemecek-filip/CompositionalDiffablePlayground.ios
댓글