저번 포스팅에서 설명했듯이 이번에는 Layout 관련된 포스팅을 작성해보려고 합니다. CompositionalLayout 으로 넘어오면서 하나의 페이지 전체를 CollectionView로 구성하는 경우도 굉장히 많아졌습니다. 그만큼 확장성 및 활용도가 굉장히 뛰어난 Layout 이라고 생각합니다. 문서를 간단하게 읽고 이후에 직접 적용해보겠습니다.

 

공식 문서

문서를 먼저 살펴보게되면, 한마디로 유연한 레이아웃 객체라고 설명할 수 있습니다. iOS 13 부터 지원을 하며 UICollectionViewLayout 을 상속받는 것을 확인할 수 있습니다.

 

@MainActor ?

 

MainActor가 붙어있는 것을 확인할 수 있습니다. 마찬가지로 iOS 13 부터 사용이 가능하며, 문서를 살펴보면 “A singleton actor whose executor is equivalent to the main dispatch queue.” 라고 되어있습니다. main dispatch queue에서 실행할 수 있도록 하는 actor라고 되어있네요. 즉, 이 친구를 통해서 Main Thread 에서 실행을 시키겠구나 라고 생각하시면 될 것 같습니다. 문서를 들어가보면 actor, @globalactor 등의 정의가 되어있는데 이 부분은 다음에 Actor를 정리할때 작성해보겠습니다. 일단은 이정도만 보고 넘어가도록 하겠습니다.

Compositional Layout의 구성

Compositional Layout 은 하나 이상의 섹션으로 이루어지며, 각 섹션은 items를 들고있는 Group 들로 이루어져 있습니다. 따라서 Compositional Layout을 구성할때는 Section Group Item 세가지를 먼저 구성해야 완성되는 것을 알 수 있습니다. 문서에 나온 예제 코드 간단하게 살펴보겠습니다. 코드가 길지만 알고보면 4개의 단계로 나뉘어져 있다는 것을 확인할 수 있습니다.

 

func createBasicListLayout() -> UICollectionViewLayout { 
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),                                  
                                         heightDimension: .fractionalHeight(1.0))    
    let item = NSCollectionLayoutItem(layoutSize: itemSize)  
    // Phase 1 : Set Item
    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),                                          
                                          heightDimension: .absolute(44))    
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,                                                   
                                                     subitems: [item])  
    // Phase 2 : Set Group
    let section = NSCollectionLayoutSection(group: group)    
    // Phase 3 : Set Section
    let layout = UICollectionViewCompositionalLayout(section: section)    
	// Phase 4 : Set Layout 
    return layout
}

처음에는 section 과 group 의 차이가 뭐지? 라는 생각을 굉장히 많이 했습니다. 쉽게 설명 드리자면 그룹이 큰 바구니이고 내부에 여러개의 아이템이 있다로 생각하시면 좋습니다. 또한 섹션의 헤더와 푸터, 인셋등을 설정할때의 기준이 그룹이 된다는 것을 인지하시면 좋습니다. 이제 예제를 만들어가면서 코드를 보고 설명을 이어나가겠습니다.

 

< Practice : App Store Carousel UI >

App Store

Compositional Layout 을 정말 잘 활용해서 만든 앱이 앱스토어라고 생각합니다. 앱스토어를 보면서 간단한 예제를 한번 만들어보겠습니다. Carousel UI가 바로 보이는군요! 세부특징을 아래와 같이 설명할 수 있을 것 같습니다.

  1. 하나의 셀의 Width가 전체 Width 의 60% 정도 (혹은 250 fix), 높이는 대략 150
  2. spacing은 8인데 양옆에는 20 정도의 inset이 있음
  3. 헤더뷰가 있고 다음 섹션과의 차이는 40 정도

디테일은 위와 같이 되어있다고 가정하고 코드를 작성해보겠습니다. 그 전에 Layout을 잡기 전에 기본 설정들을 먼저 살펴보겠습니다.

private enum Section: Int, CaseIterable {
    case iconicCharacter
    case categories
}

private typealias Datasource = UICollectionViewDiffableDataSource<Section, AnyHashable>
private typealias Snapshot = NSDiffableDataSourceSnapshot<Section, AnyHashable>

private lazy var mainCollectionView = UICollectionView(frame: .zero, collectionViewLayout: compositionalLayout)
private var compositionalLayout: UICollectionViewLayout = {
    UICollectionViewCompositionalLayout { sectionIndex, env in 
				// SectionProvider
        switch Section(rawValue: sectionIndex) {
        case .iconicCharacter:
            return baseCarouselLayout()
        case .categories:
            return baseCarouselLayout()
        case .none:
            return nil
        }
    }
}()

먼저 명확하게 Section을 선언해주었습니다. enum을 사용해 혹시 모를 human error 를 방지할 수 있기 때문에 이를 자주 활용하는 편입니다. 또한 자주 사용되는 Datasoure와 Snapshot을 typealias 로 관리할 수 있도록 하였습니다. 이렇게 레이아웃을 구성하기 전 Layout과 필요한 요소들을 미리 선언해두었습니다. 

 

UICollectionViewCompositionalLayout 설정

UICollectionViewCompositionalLayout은 위의 SectionProvider를 활용한 이니셜라이저를 통해서 만들어 주었습니다. SectionProvider의 경우 상단에 typealias로 선언되어 있으며 이를 통해서 NSCollectionLayoutSection 을 리턴해줘야 한다는 것을 알 수 있습니다. 인자로 받는 Int의 경우 sectionIndex 이며 enviroment 를 통해서 다양한 값들을 받을 수 있습니다.

 

위에서 설정한 CollectionViewLayout은 CollectionView 전체에 대한 Layout 입니다. 따라서 각 섹션에 들어갈 Layout을 설정하고 이를 리턴해줘야 합니다. 복잡해 보이지만 하나씩 조립한다고 생각하시면 조금 수월할 것입니다. 그렇다면 해당 섹션의 Layout 을 설정해주겠습니다.

 

private func baseCarouselLayout() -> NSCollectionLayoutSection {
	let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
	let item = NSCollectionLayoutItem(layoutSize: itemSize)
	
//  let groupWidth: NSCollectionLayoutDimension = .absolute(250)
  let groupWidth: NSCollectionLayoutDimension = .fractionalWidth(0.6)
  let groupSize = NSCollectionLayoutSize(widthDimension: groupWidth, heightDimension: .absolute(150))
  
  let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
  
  let section = NSCollectionLayoutSection(group: group)
			section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 40, trailing: 20)
	    section.interGroupSpacing = 8
	    section.orthogonalScrollingBehavior = .groupPaging
  return section
}

먼저 설정은 아이템 > 그룹 > 섹션 순서이지만 설계를 할때는 그 반대로 하는것이 편합니다. 사전개념 없이 해당 UI를 살펴보면 하나의 큰 카드가 옆으로 스크롤되는 것을 확인할 수 있습니다. 이때 전체적인 부분이 하나의 섹션이고 그리고 카드를 섹션 이라고 생각하면 됩니다. 그리고 그 섹션 안에 하나의 아이템이 꽉 차있다고 생각합니다. 

 

설정은 역순으로 했다고 말씀드렸습니다. 따라서 카드안에 꽉차있는 아이템을 표현해야합니다. 아이템을 설정하기 위해서 사이즈를 먼저 설정해야 합니다. 이때 사이즈의 설정에 사용되는 변수인 LayoutDemension는 사진과 같이 정의되어 있습니다.

 

여기에서 fractional이 붙은 함수를 사용하여 LayoutDemension을 잡아줍니다. 이는 주어진 영역에서 몇퍼센트를 차지할 것인가? 를 설정하는것입니다. LayoutDemension.fractionalWidth(0.5)라고 한다면 주어진 영역의 50% 를 차지한다는 뜻입니다. 꽉 차있는 아이템을 표현해야하기 때문에 1.0을 설정하여 100%를 차지하도록 하였습니다.

 

그 다음 해당 아이템이 포함된 그룹입니다. 그룹도 마찬가지로 사이즈를 설정해야합니다. 그룹의 너비는 주어진 영역의 60%를 차지하도록 하였습니다. 그리고 height의 경우 150px로 설정하기 위해서 .absolute(150)를 설정해주었습니다. fractional, absolute 이외에도 estimated를 볼수 있는데 셀의 layout에 따라 크기를 맞춰줍니다. 이는 다음 기회에 조금 더 알아보겠습니다. 그룹의 사이즈를 통해서 그룹을 만들어 줍니다. 그룹의 사이즈와 아이템들을 받는 것을 알 수 있습니다. 여러개의 아이템 레이아웃을 활용할 수 있도록 Array로 받는 것을 확인할 수 있습니다. 지금은 하나의 아이템이므로 만들어둔 아이템 하나만 넣습니다.

 

이렇게 아이템 > 그룹 설정이 완료되었습니다. 마지막으로 해당 그룹을 통해서 섹션을 만들어 줍니다. NSCollectionLayoutGroup는 크게 horizontal, vertical, custom 이렇게 나누어집니다. 여기서는 섹션들을 좌우로 스크롤할 예정이므로 horizontal을 사용합니다.

 

각 그룹의 거리는 8이어야 하므로 interGroupSpacing을 8로 설정합니다. 또한 섹션 내부의 그룹에 대한 inset을 설정해줍니다. 이를 통해서 양옆 그리고 하단의 inset을 설정해주었습니다. 마지막으로 원하는 방식대로 좌우 스크롤을 하기 위해서는 꼭 orthogonalScrollingBehavior를 설정해야 합니다. .groupPaging 으로 설정하면 원하는 carousel 동작이 되는 것을 확인할 수 있습니다. 이 외에도 .continuous, .groupPagingCentered 등등 값이 있으니 한번 직접 실행시켜 확인해보시기를 권장드립니다.

 

마지막으로 collectionView에 연결된 dataSource에 snapshot를 만들어준 뒤 적용합니다. 이를 모두 적용하면 아래와 같은 결과물을 얻을 수 있습니다. 셀의 설정등은 글이 길어져서 생략하였습니다. 필요하시다면 하단 레퍼런스를 살펴보시길 바랍니다.

 

섹션 헤더

 

마지막으로 섹션 헤더를 설정해보겠습니다. UICollectionReusableView를 만들어 collectionView에 등록해줍니다. 이후 dataSource에 각 세션마다 활용할 reusableView 를 아래와 같이 설정해줄 수 있습니다.

// supplymentaryViewProvier는 아래 SupplementaryViewProvider 입니다.
dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
	  let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind,
	                                                                   withReuseIdentifier: "BaseCarouselHeaderView",
	                                                                   for: indexPath)
	  return headerView
}

// SupplementaryViewProvider From Document
public typealias SupplementaryViewProvider = (_ collectionView: UICollectionView, _ elementKind: String, _ indexPath: IndexPath) -> UICollectionReusableView?

동일하게 indexPath를 통해서 각 섹션에 필요한 view를 설정할 수 있습니다. 이후 supplymentaryView의 Layout을 설정해야 합니다. NSCollectionLayoutSection에 아래와 같이 작성하겠습니다.

// ... 중략
section.orthogonalScrollingBehavior = .groupPaging

let supplementaryItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(20))
let supplementaryItem = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: supplementaryItemSize,
                                                                    elementKind: UICollectionView.elementKindSectionHeader,
                                                                    alignment: .top)
section.boundarySupplementaryItems = [supplementaryItem]
return section

위에서 설정한 것처럼 먼저 size를 잡아줍니다. 그리고 kind를 설정한 뒤 header를 어디에 위치시킬것인지 설정합니다. 일단 alignment 를 .top으로 설정해주면 아래와 같은 결과가 나옵니다. ( 구분하기 편하도록 collectionView에 검정색 border를 넣고 Section Header에는 background color를 주었습니다. )

 

나름 성공적이지만, 조금만 더 들어가보겠습니다. 만약 섹션 헤더를 왼쪽으로 붙이고 싶다면 어떻게 할까요? 단순히 alignment를 .topleading으로 설정하면 끝일 것 같으나, 결과는 위와 동일하게 양옆에 inset이 남아있습니다. 그 이유는 layoutSize의 width가 fractionalWidth(1.0)이기 때문입니다. 이로 인해 저 상태가 top, leading, trailing가 이미 붙어있는 형태가 됩니다.

 

따라서 양 옆의 inset만큼 조절이 필요합니다. NSCollectionLayoutBoundarySupplementaryItem 이니셜라이저 중에는 absoluteOffset을 받을 수 있는 이니셜라이저가 있습니다. 섹션의 leading inset이 20이므로 아래와 같이 absoluteOffset을 설정한다면 원하는 결과가 나오는 것을 확인할 수 있습니다.

// ...
NSCollectionLayoutBoundarySupplementaryItem(layoutSize: supplementaryItemSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .topLeading, absoluteOffset: CGPoint(x: -20, y: 0))
// ...

딱 붙는다.

하지만 여전히 SectionHeader가 상하단과 붙어있는 UI가 살짝 불편하네요. 상하단에 10씩 inset을 주고싶다면 어떻게 하면 될까요? alignment를 .top으로 주고 absoluteOffset의 Y를 10으로 주면 될까요?? 상단에 inset이 생길것 같으나 그렇지 않습니다. 말한 내용을 적용한 UI를 살펴보면 아래 그림과 같습니다.

???

 

섹션헤더의 영역을 30이라고 가정했을때 10만큼 아래로 밀려나는것을 확인할 수 있습니다. 이유는 이미 alignment를 통해서 .top에 붙어있기 때문에 offset을 주더라도 섹션 헤더와 상단의 inset은 생기지 않습니다. 즉 alignment를 통한 제어로는 원하는 결과를 얻을 수 없습니다.

 

이를 위해서는 NSCollectionLayoutSupplementaryItem의 이니셜라이저 중에서 NSCollectionLayoutAnchor를 인자로 받는 이니셜라이저를 사용해야 합니다. NSCollectionLayoutAnchor 설명을 보면 8방향을 설정할 수 있으며, 여기에 offset을 설정할 수 있습니다. Anchor라는 이름을 보시면 아시겠지만, alignment와 다르게 offset이 설정되는것을 짐작할 수 있습니다.

 

이것이 끝이면 좋겠으나 하나의 아쉬운 점이 남아있습니다. alignment를 통해서 설정한 경우 section Header를 위한 영역을 마련해주었지만, anchor는 그렇지 못합니다. 애초에 alignment를 받는 이니셜라이저는 NSCollectionLayoutBoundarySupplementaryItem에 있으나, anchor를 받는 이니셜라이저는 그 부모인 NSCollectionLayoutSupplementaryItem에 있습니다. 상속받은 만큼 편하게 만들어둔 것이죠.

 

따라서 Section의 topInset은 10(상단 inset) + 30(sectionHeader Height) + 10(하단 inset) = 50 이 되어야 하고, anchor를 아래와 같이 설정하면 50 내부에 Section Header가 위치하는 것을 확인할 수 있습니다. 완성된 layout 코드는 아래와 같습니다.

private func baseCarouselLayout() -> NSCollectionLayoutSection {
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    
//        let groupWidth: NSCollectionLayoutDimension = .absolute(250)
    let groupWidth: NSCollectionLayoutDimension = .fractionalWidth(0.6)
    let groupSize = NSCollectionLayoutSize(widthDimension: groupWidth, heightDimension: .absolute(150))
    
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
    
    let section = NSCollectionLayoutSection(group: group)
		// top을 50으로 변경!
    section.contentInsets = NSDirectionalEdgeInsets(top: 50, leading: 20, bottom: 40, trailing: 20)
    section.interGroupSpacing = 8
    section.orthogonalScrollingBehavior = .groupPaging
    
		let supplementaryItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(20))
    let supplementaryItemLayoutAnchor = NSCollectionLayoutAnchor(edges: [.top], absoluteOffset: CGPoint(x: 0, y: 10))
    let supplementaryItem = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: supplementaryItemSize,
                                                                        elementKind: UICollectionView.elementKindSectionHeader,
                                                                        containerAnchor: supplementaryItemLayoutAnchor)
    section.boundarySupplementaryItems = [supplementaryItem]
    return section
}

 

Reference

ViewController Code

Cell Code

 

HeaderView Code

 

https://developer.apple.com/documentation/uikit/uicollectionviewcompositionallayout

https://developer.apple.com/documentation/swift/mainactor

https://developer.apple.com/documentation/uikit/nscollectionlayoutboundarysupplementaryitem/3213820-init

https://developer.apple.com/documentation/uikit/nscollectionlayoutanchor

 

 

 

 

'iOS & Swift' 카테고리의 다른 글

스위프트 성능 이해하기 - Let's Swift  (0) 2023.06.20
UINib Basic  (3) 2021.10.04
Property Wrapper  (0) 2021.06.07
Apple 공식 Guide & Reference 모음  (0) 2021.05.21
Escaping Closure  (0) 2021.05.16

+ Recent posts