뷰 컨트롤러에서 dataSource 프로토콜을 채택하게되면 기본적으로 실행해야할 메소드들이 있고, 이를 통해 우리는 셀에 대한 데이터를 가져와 화면에 보여줄 수 있게 됩니다.
그런데 WWDC19부터 이번에 발표했던 WWDC21까지 dataSource를 넘어서 cell에 대한 새로운 기능, 더 확장되고 심화된 개념의 컬렉션뷰가 등장하였습니다.
애플에서도 이를 Advances CollectionView라 칭하면서 그 안에 다양한 기능들을 소개하고 있습니다.
이번 포스팅에서 저는 이 Advances CollectionView에서 소개하는 크게 총 2가지의 기능을 사용해 프로젝트를 구성해보려고 합니다.
Diffable DataSource / Section Snapshot
UICollectionViewCompositionalLayout
만들어볼 화면 구성은 다음과 같습니다.
CollectionView를 통해 json 파일 내 데이터를 보여준다.
CollectionView 내 데이터 구성은 총 3개의 Section으로 나누어 보여준다.
해당 데이터는 DiffableDataSource를 통해 Cell에 나타내도록 한다.
각 셀은 커스텀 셀로 구성되어진다.
추가적으로 확인해 볼 내용은 다음과 같습니다.
셀이 무한 스크롤 되는 경우 dataSource와 layout이 어떻게 호출되는 지 확인
섹션 사이에 다른 섹션이 들어갈 수 있는지 확인
1~4까지는 기본적으로 만들어볼 화면이며, 추가 1,2는 글 하단에 추가적으로 다뤄볼 예정입니다.
Advances CollectionView - CustomCell로 구성해보기
우선 프로젝트에 들어가기 앞서 저는 json 파일을 제 프로젝트 내에 넣어놓고 해당 파일을 불러와 데이터를 가져올 것입니다.
json 파일은 아래와 같습니다. (Home.json)
{"companyInfoList":[{"companyName":"병원","addrRoad":"서울 특별시","introPath":""},],"expertList":[{"expertInfo":{"expertTypeName":"한의사","name":"박지혜","profilePath":""}}],"consultList":[{"regDate":1635472394000,"readCnt":22,"title":"감기에 걸렸을때 어느 병원으로 가야할까요?","lastAnswer":{"content":"안녕하세요. 박지혜입니다.\n","profileImg":"",}}]}
해당 데이터를 불러오기 위해 모델을 구성해보겠습니다.
json 파일 데이터를 불러오기 위해 swiftyJSON을 사용하였습니다.
각 섹션과 아이템들의 위치 즉 레이아웃을 잡아주기 위해 우리는 UICollectionViewCompositionalLayout을 사용할 것입니다.
이 개념을 이해하기 위한 사진 하나를 보여드리겠습니다.
해당 사진에서 유추할 수 있듯 각 레이아웃을 잡아주는 순서는 다음과 같습니다.
item에서 group
group에서 section
이렇게 결합한 뒤 전체 layout으로 구성요소 결합
각 섹션에 대한 코드는 다음과 같습니다.
funccreateLayout()->UICollectionViewLayout{print("createLayout")letsectionProvider={(sectionIndex:Int,layoutEnvironment:NSCollectionLayoutEnvironment)->NSCollectionLayoutSection?inguardletsectionKind=Section(rawValue:sectionIndex)else{returnnil}letsection:NSCollectionLayoutSectionifsectionKind==.consult{print("layout consult")// item의 width와 height 지정letitemSize=NSCollectionLayoutSize(widthDimension:.fractionalWidth(1.0),heightDimension:.fractionalHeight(1.0))letitem=NSCollectionLayoutItem(layoutSize:itemSize)item.contentInsets=NSDirectionalEdgeInsets(top:2,leading:2,bottom:2,trailing:2)letgroupHeight=NSCollectionLayoutDimension.absolute(200)letgroupSize=NSCollectionLayoutSize(widthDimension:.fractionalWidth(1.0),heightDimension:groupHeight)// 그룹 내 1.0 배율 안에서 보여질 아이템의 수는 1개라는 의미letgroup=NSCollectionLayoutGroup.horizontal(layoutSize:groupSize,subitem:item,count:1)section=NSCollectionLayoutSection(group:group)}elseifsectionKind==.company{print("layout company")letitemSize=NSCollectionLayoutSize(widthDimension:.fractionalWidth(1.0),heightDimension:.fractionalHeight(1.0))letitem=NSCollectionLayoutItem(layoutSize:itemSize)letgroupSize=NSCollectionLayoutSize(widthDimension:.absolute(350),heightDimension:.absolute(200))letgroup=NSCollectionLayoutGroup.horizontal(layoutSize:groupSize,subitems:[item])section=NSCollectionLayoutSection(group:group)section.interGroupSpacing=10section.orthogonalScrollingBehavior=.continuousGroupLeadingBoundarysection.contentInsets=NSDirectionalEdgeInsets(top:10,leading:10,bottom:10,trailing:10)}elseifsectionKind==.expert{print("layout expert")letitemSize=NSCollectionLayoutSize(widthDimension:.fractionalWidth(1.0),heightDimension:.fractionalHeight(1.0))letitem=NSCollectionLayoutItem(layoutSize:itemSize)letgroupSize=NSCollectionLayoutSize(widthDimension:.absolute(150),heightDimension:.absolute(150))letgroup=NSCollectionLayoutGroup.horizontal(layoutSize:groupSize,subitems:[item])section=NSCollectionLayoutSection(group:group)section.interGroupSpacing=10section.orthogonalScrollingBehavior=.continuousGroupLeadingBoundarysection.contentInsets=NSDirectionalEdgeInsets(top:10,leading:10,bottom:10,trailing:10)}else{fatalError("Unknown section!")}returnsection}returnUICollectionViewCompositionalLayout(sectionProvider:sectionProvider)}
코드를 살펴보면 Consult Section의 경우 수직으로 스크롤이 되고 있으며, 나머지 Compant, Expert Section은 수평으로 스크롤이 되고있음을 알 수 있습니다.
이를 구분짓는 코드는 section.orthogonalScrollingBehavior입니다.
하나의 섹션 단위의 스크롤 방향을 지정해주는 것으로 이 행위를 넣지 않으면 디폴트는 수직입니다.
그래서 데이터들이 아래로 쌓여져 보이게 될 것입니다.
추가로 코드 안에서 NSCollectionLayoutSize를 통해 item과 group의 사이즈(width, height)를 구성하고 있음을 볼 수 있습니다.
importUIKitstructEmoji:Hashable{enumCategory:CaseIterable,CustomStringConvertible{caserecents,smileys,nature,food,activities,travel,objects,symbols}lettext:Stringlettitle:Stringletcategory:Categoryprivateletidentifier=UUID()}extensionEmoji.Category{vardescription:String{switchself{case.recents:return"Recents"case.smileys:return"Smileys"case.nature:return"Nature"case.food:return"Food"case.activities:return"Activities"case.travel:return"Travel"case.objects:return"Objects"case.symbols:return"Symbols"}}varemojis:[Emoji]{switchself{case.recents:return[Emoji(text:"🤣",title:"Rolling on the floor laughing",category:self),Emoji(text:"🥃",title:"Whiskey",category:self),Emoji(text:"😎",title:"Cool",category:self),Emoji(text:"🏔",title:"Mountains",category:self),Emoji(text:"⛺️",title:"Camping",category:self),Emoji(text:"⌚️",title:" Watch",category:self),Emoji(text:"💯",title:"Best",category:self),Emoji(text:"✅",title:"LGTM",category:self)]case.smileys:return[Emoji(text:"😀",title:"Happy",category:self),Emoji(text:"😂",title:"Laughing",category:self),Emoji(text:"🤣",title:"Rolling on the floor laughing",category:self)]case.nature:return[Emoji(text:"🦊",title:"Fox",category:self),Emoji(text:"🐝",title:"Bee",category:self),Emoji(text:"🐢",title:"Turtle",category:self)]case.food:return[Emoji(text:"🥃",title:"Whiskey",category:self),Emoji(text:"🍎",title:"Apple",category:self),Emoji(text:"🍑",title:"Peach",category:self)]case.activities:return[Emoji(text:"🏈",title:"Football",category:self),Emoji(text:"🚴♀️",title:"Cycling",category:self),Emoji(text:"🎤",title:"Singing",category:self)]case.travel:return[Emoji(text:"🏔",title:"Mountains",category:self),Emoji(text:"⛺️",title:"Camping",category:self),Emoji(text:"🏖",title:"Beach",category:self)]case.objects:return[Emoji(text:"🖥",title:"iMac",category:self),Emoji(text:"⌚️",title:" Watch",category:self),Emoji(text:"📱",title:"iPhone",category:self)]case.symbols:return[Emoji(text:"❤️",title:"Love",category:self),Emoji(text:"☮️",title:"Peace",category:self),Emoji(text:"💯",title:"Best",category:self)]}}}
모델은 단순합니다.
위에서 정리했던 것처럼 Emoji 구조체는 Hashable 프로토콜을 채택하고 있습니다.
그 이유는 전에도 말씀드렸던 것처럼 Diffable DataSource는 넘어오는 데이터의 고유함을 반드시 지켜주어야하기 때문입니다.
그리고 아래는 Emoji.Category에 들어가는 각 케이스별 리턴값들을 정의해주고 있습니다.
Section과 Item
실제 우리가 보여줄 Section과 Item을 정의해봅니다.
importUIKitclassViewController:UIViewController{enumSection:Int,Hashable,CaseIterable,CustomStringConvertible{// 섹션에 들어갈 데이터 정의caserecents,outline,list,customvardescription:String{switchself{case.recents:return"Recents"case.outline:return"Outline"case.list:return"List"case.custom:return"Custom"}}}structItem:Hashable{// 섹션 속 아이템에 들어갈 데이터 정의lettitle:String?letemoji:Emoji?lethasChild:Boolinit(emoji:Emoji?=nil,title:String?=nil,hasChild:Bool=false){self.emoji=emojiself.title=titleself.hasChild=hasChild}privateletidentifier=UUID()}}
이때 등장하는 UICollectionLayoutListConfiguration 이건 무엇일까요?
iOS14에서는 UICollectionLayoutListConfiguration 라는 새로운 유형을 제공합니다.
list configuration은 테이블뷰 스타일(.plain, .grouped, insetGrouped)과 같은 모양을 제공합니다.
또한 콜렉션 뷰 List 전용 .sideBar, .sideBarPlain 이라는 새로운 스타일 또한 제공함으로써 다중 열 앱을 구축할 수 있게 되었습니다.
따라서 위와 같은 configuration들을 지정해줌으로써 컬렉션뷰에서도 다양한 리스트 형태의 레이아웃을 만들어 낼 수 있게 된것입니다.
그게 바로 지금 저희가 만들 앱 화면의 모습이기도 합니다.
Create Cell and Resister
이제 컬렉션 뷰 내 보여질 섹션과 아이템들의 레이아웃 구성을 하였으니 본격적으로 셀을 만들어주고 지정해보도록 합니다.
// recent > grid cell registrationfunccreateGridCellRegistration()->UICollectionView.CellRegistration<UICollectionViewCell,Emoji>{// 셀 등록 > iOS14부터는 cell registration을 통해 새롭게 cell을 구성할 수 있음returnUICollectionView.CellRegistration<UICollectionViewCell,Emoji>{(cell,indexPath,emoji)in// 테이블 뷰와 같이 셀에 대한 표준화된 레이아웃을 제공varcontent=UIListContentConfiguration.cell()content.text=emoji.textcontent.textProperties.font=.boldSystemFont(ofSize:38)content.textProperties.alignment=.centercontent.directionalLayoutMargins=.zerocell.contentConfiguration=contentvarbackground=UIBackgroundConfiguration.listPlainCell()background.cornerRadius=8background.strokeColor=.systemGray3background.strokeWidth=1.0/cell.traitCollection.displayScalecell.backgroundConfiguration=background}}// outline header cell registrationfunccreateOutlineHeaderCellRegistration()->UICollectionView.CellRegistration<UICollectionViewListCell,String>{returnUICollectionView.CellRegistration<UICollectionViewListCell,String>{(cell,indexPath,title)invarcontent=cell.defaultContentConfiguration()content.text=titlecell.contentConfiguration=content}}// outline cell registrationfunccreateOutlineCellRegistration()->UICollectionView.CellRegistration<UICollectionViewListCell,Emoji>{returnUICollectionView.CellRegistration<UICollectionViewListCell,Emoji>{(cell,indexPath,emoji)invarcontent=cell.defaultContentConfiguration()content.text=emoji.textcontent.secondaryText=emoji.titlecell.contentConfiguration=content}}// list > list cell registrationfunccreateListCellRegistration()->UICollectionView.CellRegistration<UICollectionViewListCell,Item>{returnUICollectionView.CellRegistration<UICollectionViewListCell,Item>{[weakself](cell,indexPath,item)inguardletself=self,letemoji=item.emojielse{return}varcontent=UIListContentConfiguration.valueCell()content.text=emoji.textcontent.secondaryText=String(describing:emoji.category)cell.contentConfiguration=content}}
이때 또 새로운 개념이 보이죠?
UIListContentConfiguration
defaultContentConfiguration
UIListContentConfiguration은 list based content view에 대한 content configuration을 의미합니다.
defaultContentConfiguration은 우리가 기본적으로 테이블뷰 혹은 컬렉션뷰를 사용할때 각 셀안에 이미 존재하는 textLabel, imageView등을 사용하였습니다.
근데 이제 이러한 접근이 iOS14에서부터는 deprecated 되어 접근이 불가능해졌습니다.
그래서 이때 셀에 각 데이터에 접근하기 위해 defaultContentConfiguration에 접근해야합니다.
그리고 이제 셀을 등록하기 위해 UICollectionView.CellRegistration를 통해 등록하는 것을 볼 수 있습니다.
Diffable DataSource
이제 이렇게 만들어 놓은 셀에 대한 데이터 작업을 해봅니다.
vardataSource:UICollectionViewDiffableDataSource<Section,Item>!funcconfigureDataSource(){// create registrations up front, then choose the appropriate one to use in the cell providerletgridCellRegistration=createGridCellRegistration()letlistCellRegistration=createListCellRegistration()letoutlineHeaderCellRegistration=createOutlineHeaderCellRegistration()letoutlineCellRegistration=createOutlineCellRegistration()letcreatePlaceRegistration=createPlainCellRegistration()// data sourcedataSource=UICollectionViewDiffableDataSource<Section,Item>(collectionView:collectionView){(collectionView,indexPath,item)->UICollectionViewCell?inguardletsection=Section(rawValue:indexPath.section)else{fatalError("Unknown section")}switchsection{// recentcase.recents:returncollectionView.dequeueConfiguredReusableCell(using:gridCellRegistration,for:indexPath,item:item.emoji)// 맨 아래 listcase.list:returncollectionView.dequeueConfiguredReusableCell(using:listCellRegistration,for:indexPath,item:item)// outline > header, cellcase.outline:ifitem.hasChild{returncollectionView.dequeueConfiguredReusableCell(using:outlineHeaderCellRegistration,for:indexPath,item:item.title!)}else{returncollectionView.dequeueConfiguredReusableCell(using:outlineCellRegistration,for:indexPath,item:item.emoji)}case.custom:returncollectionView.dequeueConfiguredReusableCell(using:createPlaceRegistration,for:indexPath,item:item)}}}
Snapshot
만들어놓은 데이터소스에 스냅샷을 적용하는 코드입니다.
// 스냅샷 적용// NSDiffableDataSourceSnapshot > 데이터 접근, 특정 인덱스에 데이터 삽입 및 삭제 가능 > apply 통해 변경사항 적용funcapplyInitialSnapshots(){letsections=Section.allCasesvarsnapshot=NSDiffableDataSourceSnapshot<Section,Item>()snapshot.appendSections(sections)dataSource.apply(snapshot,animatingDifferences:false)// recentsletrecentItems=Emoji.Category.recents.emojis.map{Item(emoji:$0)}varrecentsSnapshot=NSDiffableDataSourceSectionSnapshot<Item>()recentsSnapshot.append(recentItems)// list + outlinesvarallSnapshot=NSDiffableDataSourceSectionSnapshot<Item>()varoutlineSnapshot=NSDiffableDataSourceSectionSnapshot<Item>()forcategoryinEmoji.Category.allCaseswherecategory!=.recents{// append to the "all items" snapshotletallSnapshotItems=category.emojis.map{Item(emoji:$0)}allSnapshot.append(allSnapshotItems)// setup our parent/child relationsletrootItem=Item(title:String(describing:category),hasChild:true)outlineSnapshot.append([rootItem])letoutlineItems=category.emojis.map{Item(emoji:$0)}outlineSnapshot.append(outlineItems,to:rootItem)}letcustomItems=Emoji.Category.recents.emojis.map{Item(emoji:$0)}varcusomSnapshot=NSDiffableDataSourceSectionSnapshot<Item>()cusomSnapshot.append(customItems)dataSource.apply(recentsSnapshot,to:.recents,animatingDifferences:false)dataSource.apply(allSnapshot,to:.list,animatingDifferences:false)dataSource.apply(outlineSnapshot,to:.outline,animatingDifferences:false)dataSource.apply(cusomSnapshot,to:.list,animatingDifferences:false)}}