Compostional Layout
Compositional Layout은 iOS 13버전부터 지원되는 UICollectionViewLayout 의 한 유형입니다. 유연하고 빠르게 설계되어, 작은 구성 요소를 결합하는 방법으로 레이아웃을 구성하고 이를 통해 시각적 배열을 원하는 형태로 구성할수 있습니다.
대표적으로는 앱스토어 앱이나, 유튜브 뮤직 앱과 같은 레이아웃 구성을 쉽게 할수 있도록 해주는것이 바로 Compositional Layout입니다.
Compositional Layout 구성
Compostional Layout은 크게 3가지로 구성됩니다.
- Item : 단일 Cell
- Group : 아이템 또는 그룹 으로 구성된 하나의 단위
- Section : 그룹들로 이루어진 하나의 단위
레이아웃은 데이터소스와 상관없이, 단순히 크기만을 정의하는 방식으로 구현하기 때문에 선언적으로 사용하기 굉장히 수월합니다.
NSCollectionLayoutDimension
레이아웃 디멘션을 통해서 레이아웃을 지정해주게 되는데요, 이때 레이아웃의 옵션은 총 3개가 있습니다.
- .absolute : 고정사이즈 줄 때 사용
- .estimate : 크기를 모르거나, 자동으로 변경이 필요할 때 사용
- .fractional : 비율로 사이즈 줄 때 사용
- fractionalWidth : 상위 개념의 너비에서 비율로 사이즈를 준다.
- fractionalHeight : 상위 개념의 높이에서 비율로 사이즈를 준다.
위의 개념을 가지고 간단하게 코드를 작성하면 다음과 같은 순서로 작성하게 됩니다.
1. 아이템 크기 정하기
아이템은 NSCollectionLayoutItem객체를 통해서 생성해주고, size를 지정해줘야 합니다.
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1/3),
heightDimension: .fractionalHeight(1)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
2. 그룹 만들기
그룹은 NSCollectionLayoutGroup으로 표현됩니다. 섹션에 대한 크기를 설명하고, 수평 수직으로 배치할지 정하게 되며 포함할 항목(아이템과 그룹) 을 설명해줘야 합니다.
이때, 중요한점은 subitems의 갯수는 추후 동적으로 변경될수 없다는 점을 고려해야합니다.
따라서 동적으로 변하게 된다면 이는 섹션이 되어야 합니다.
- 그룹 생성시 수평 수직에 관련된 생성자
- NSCollectionLayoutGroup.horizontal(layoutSize: subitems: )
- NSCollectionLayoutGroup.vertical(layoutSize: subitems: )
그룹크기 정하기
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalWidth(1/3)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
subtitems: [item]
)
각 그룹에 3개의 아이템을 수평으로 배치할것이고, 정사각형으로 할것이기 때문에 너비를 3으로 나누는 동작을 하는 코드입니다.
3. 섹션 추가
let section = NSCollectionLayoutSection(group: group)
let layout = NSCollectionViewCompositionalLayout(section: section)
return layout
4. 최종 구현 코드
let compositionalLayout: UICollectionViewCompositionalLayout = {
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1/3),
heightDimension: .fractionalHeight(1)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalWidth(1/3)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
subitems: [item]
)
let section = NSCollectionLayoutSection(group: group)
let layout = NSCollectionViewCompositionalLayout(section: section)
return layout
}()
처음 Compositional Layout에 대해서 이해할때는, 높이랑 사이즈 그리고 비율에 대해서 가장 많이 이해하기가 어렵습니다. 그리고 스페이싱에 대해서도 많이 어려워요 이부분에 대해서도 한번 짚고 넘어가봅시다.
5. 스페이싱 주기
(각각의 섹션, 그룹, 아이템에는 NSDirectionalEdgeInsets()를 통해서 패딩을 줄 수 있다.)
위를 통해서 해당 값에서 group이 어떻게 구현되어있는지 명확하게 확인을 할수 있습니다. 비율을 따지고 보게 되면,
- group은 section의 너비에 fractionalWidth(1) 이니까 100프로라서, section과 너비가 동일
- gorup의 높이는 fractionalWidth(1/3) 이므로 섹션의 너비에서 나누기 3한것과 동일한 높이
- Item의 너비는 fractionalWidth(1/3)이므로 group의 너비에서 나누기 3한것과 동일
- item의 높이는 너비에 fractionalWidth(1) 이니까 100프로라서, group과 너비가 동일
그렇기 때문에 그룹에는 아이템이 3개가 들어가고, 섹션의 너비에서 1/3한것과 높이와 너비가 동일한 정사각형의 아이템이 들어가게 된다. 또한 상하좌우로 2.5의 패딩을 주었기 때문에 아이템 사이의 패딩은 5만큼의 패딩이 들어가게 되고, 그룹에서도 그룹상하에는 5, 좌우에는 2.5씩 들어가게 된 형태가 됩니다.
보조뷰 추가하기 (SupplementaryItem)
Compositional Layout에서 SupplementaryItem은 크게 두개가 있습니다.
- NSCollectionLayoutSupplementaryItem
- 특정 아이템이나 그룹 내부에 추가 정보를 표시할 때 사용
- 예를 들어, 특정 셀 옆이나 그룹 내 특정 위치에 배치되는 배지(badge)나 작은 아이콘 등을 구현
- NSCollectionLayoutBoundarySupplementaryItem
- 섹션의 경계를 정의하는 보조 뷰를 만들 때 사용
- 주로 섹션의 헤더(Header)나 푸터(Footer) 같은 역할
먼저 Elementkind를 추가해줍니다. (고유한 kind값을 넣기위한거라 다양하게 해도 상관없습니다.)
struct ElementKind {
static let badge = "badge"
static let background = "background"
static let sectionHeader = "section-header"
static let sectionFooter = "section-footer"
}
- 뷰 등록(reusableView)
collectionView.register(
UINib:(nibName: "supplementaryView", bundle: nil),
forSupplementaryViewofKind: ElementKind.badge,
withReuseIdentifier: "supplementaryView"
)
- item에 추가
let supplementarySize = NSCollectionLayoutSize(
widthDimension: .absolute(20),
heightDimension: .absolute(20)
)
let badgeSupplementaryItem = NSCollectionLayoutSupplementaryItem(
layoutSize: supplementarySize,
elementKind: ElementKind.badge,
containerAnchor: NSCollectionLayoutAnchor(edges: [.top, .trailing], fractionalOffset: CGPoint(x: -0.1, y: 0.1))
)
item = NSCollectionLayoutItem(layoutSize: itemSize, supplementaryItems: [badgeSupplementaryItem])
추가할때는, ancheor를 통해서 위치를 잡아줄수 있다.
- supplementaryView정의
func collectionView(
_ collectionView: UICollectionView,
viewForSupplementaryElementOfKind kind: String,
at indexPath: IndexPath
) -> UICollectionReusableView {
switch kind {
case "badge":
guard let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "badge", for: indexPath) as? BadgeView else {
return BadgeView()
}
defualt:
return UICollectionReusableView()
}
만약 특정 row혹은 어디에서 보여주기 싫다면 indexPath.row == 해서 isHidden처리해주면 됩니다.
장식뷰 추가하기 (DecorationView)
supplementaryview는 datasource와 연결되어 있는 보조뷰이며, 데이터소스에 있는 Dequeue메소드를 통해서 생성 및 재사용 된다.
하지만 decorationView는 layout을 커스텀할때 레이아웃 객체에 등록하고 배치하게 되는, 뷰로
데이터소스와는 연관이 없는 구분선, 배경 과 같은 화면을 의미한다.
- 뷰 생성
final class BackgroundDecorationView: UICollectionReusableView {}
- 레이아웃에 객체 추가하기
let backgroundItem = NSCollectionLayoutDecorationItem.background(
elementKind: ElementKind.background
)
section.decorationItem = [backgroundItem]
let layout = UICollectionViewCompositionalLayout(section: section)
layout.register(BackgroundDecorationView.self, forDecorationViewOfKind: "background")
- inset 추가
기본적으로 backgroundView의 경우 Section과 동일한 사이즈입니다. 이때 contentInsets의 경우, section과 독립적으로 추가된 데코레이션뷰에만 적용되기때문에 이를 고려해서 inset을 줘야합니다.
실제로 inset을 주면 다음과 같이 나온다.
좀더 복잡한 레이아웃을 위한 NestedGroup
위와 같은 레이아웃을 구성하기 위해서는 크게 두가지 범주로 나눌수 있습니다.
- 외부 그룹의 아이템, 내부그룹의 아이템
- 외부 그룹과 , 내부 그룹
- 외부그룹은 2개의 항목을 포함하고 있으며 너비의 절반을 차지함
- 내부그룹의 너비는 그룹전체의 1/2와 해당 값의 높이 또한 너비의 1/2
이를 기반으로 아이템부터 차근차근 사이즈를 정해보면
- item 사이즈 정하기
let largeItemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(0.5), height: .fractionalHeight(1)
)
let largeItem = NSCollectionLayoutItem(layoutSize: largeItemSize)
let smallItemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1), height: .fractionalHeight(0.5)
)
let smallItem = NSCollectionLayoutItem(layoutSize: smallItemSize)
- group 사이즈 정하기
let outerGroupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1), height: .fractionalWidth(0.5)
)
let outerGroup = NSCollectionLayoutGroup.horizontal(
layoutSize: outerGroupSize,
subitems: [largeItem, innerGroup, innerGroup]
)
let innerGroupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(0.25), height: .fractionalHeight(1)
)
let innerGroup = NSCollectionLayoutGroup.vertical(
layoutSize: innerGroupSize,
subitems: [smallItem]
)
- section 생성하기
let section = NSCollectionLayoutSection(group: outerGroup)
return UICollectionViewCompositionalLayout(sectino: section)
위와 같은 방법으로 nestedGroup을 생성할수 있다.
직교 스크롤(orthogonal scroll) 적용하기
특정 Section에 대해서 서로 다른 스크롤 방향을 주기 위해서는 orthogonalScrollingBehavior를 사용할 수 있습니다.
orthogonalScrolllBehavior은 5가지 속성이 존재합니다.
// Standard scroll view behavior: UIScrollViewDecelerationRateNormal
case continuous
// Scrolling will come to rest on the leading edge of a group boundary
case continuousGroupLeadingBoundary
// Standard scroll view paging behavior (UIScrollViewDecelerationRateFast) with page size == extent of the collection view's bounds
case paging
// Fractional size paging behavior determined by the sections layout group's dimension
case groupPaging
// Same of group paging with additional leading and trailing content insets to center each group's contents along the orthogonal axis
case groupPagingCentered
추가적으로 layout을 변화하게 하는법(feat. animation)
NSCollectionLayoutSection 은 visibleItemsInvalidationHandler 을 가지고 있습니다.
A closure called before each layout cycle to allow modification of the items in the section immediately before they are displayed. -"각 레이아웃 주기 전에 호출되는 클로저로, 항목이 표시되기 직전에 섹션의 항목을 수정할 수 있도록 합니다.”
아래와 같은 코드를 통해서 cell이 생성될때 다음과 같이 변화하도록 할수 있습니다.
section.visibleItemsInvalidationHandler = { (items, offset, environment) in
items.forEach { item in
let distanceFromCenter = abs((item.frame.midX - offset.x) - environment.container.contentSize.width / 2.0)
let minScale: CGFloat = 0.7
let maxScale: CGFloat = 1.1
let scale = max(maxScale - (distanceFromCenter / environment.container.contentSize.width), minScale)
item.transform = CGAffineTransform(scaleX: scale, y: scale)
}
}
실제로 전통적인 UICollectionView의 delegate메소드에서 scroll에 대한 감지를 해당 값을 통해서 읽어오게 됩니다.
🏭 팩토리 패턴을 통해서 Multi-Section을 생성하는 방법
팩토리 패턴 적용 코드
객체 생성 로직을 별도 '팩토리 객체'로 분리하여, 생성자 호출을 직접 하지 않고, 필요에 따라 다양한 객체를 생성할 수 있게 하는 디자인 패턴입니다.
Compositional Layout을 사용할때 팩토리 패턴 적용 시 장점은 다음과 같습니다.
항목 설명
✅ 모듈화 | 각 섹션의 레이아웃 코드가 별도 함수로 분리되어 가독성, 유지보수성 향상 |
✅ 재사용성 | 같은 타입의 섹션이 여러 곳에서 재사용 가능 |
✅ 테스트 용이 | 특정 레이아웃 로직만 단위 테스트 가능 |
✅ 확장성 | 새로운 섹션 타입이 생겨도 createLayout(for:)에 case 하나만 추가하면 됨 |
✅ 뷰 컨트롤러 코드 최소화 | 뷰 컨트롤러가 '레이아웃이 어떤지'를 몰라도 되기때문에 SRP가 지켜짐 |
즉 섹션 타입별 레이아웃 코드를 switch로 분리함으로써 가독성, 유지보수성, 재사용성을 모두 챙길 수 있습니다.
- SectionType정의하기
rxdatasource를 차용하여 섹션 모델을 구성하였습니다.
enum BenefitTabSectionType {
case bannerASection
case bannerBSection(title: String)
case bannerCSection
case categorySection
case recommendedContentSection(title: String)
case crossBorderShoppingSection(title: String)
// 홈최하단 컬렉션은 2개의 섹션으로 이루어져 있습니다.
case homeCollectionThumnailWithHeaderSection(title: String, description: String)
case homeCollectionThumnailSection
case homeCollectionContentSection
}
enum BenefitTabItem {
case bannerAItem(BenefitBannerASectionModel)
case bannerBItem(BenefitBannerBSectionsModel)
case bannerCItem(BenefitBannerCDSectionsModel)
case recommendedContentItem(BenefitRecommandationSectionModel)
case storeItem(BenefitCrossBorderShoppingSectionModel)
case categoryItem(BenefitCategorySectionItemModel)
case collectionThumnailItem(BenefitCollectionThumnailItem)
case collectionContentItem(BenefitContentItemModel)
case collectionDivider
}
struct BenefitSectionModel {
var sectionType: BenefitTabSectionType
var items: [BenefitTabItem]
}
extension BenefitSectionModel: SectionModelType {
typealias Item = BenefitTabItem
init(original: BenefitSectionModel, items: [Item]) {
self = original
self.items = items
}
}
- 프로토콜 정의하기
protocol CompositionalLayoutFactoryProtocol {
associatedtype T
func createLayout(by sectionType: T) -> NSCollectionLayoutSection
}
먼저 프로토콜을 정의하였습니다. 이때, associatedtype을 통해서 범용적으로 적용할수 있도록 만들었습니다.
- 팩토리 객체 정의하기
final class SelectICCardLayoutFactory: CompositionalLayoutFactoryProtocol {
func createLayout(by section: SelectICCardSection) -> NSCollectionLayoutSection {
switch section {
case .selectCardSection:
selectCardSectionLayout()
case .titleSection:
titleSectionLayout()
default: contentSectionLayout()
}
}
}
- 각각의 레이아웃 정의하기
extension BenefitTabCompositionalLayoutFactory {
func createbannerALayout() -> NSCollectionLayoutSection {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let height = (UIScreen.main.bounds.width - TWGap.l * 2) + 8
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(height))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: TWGap.l, trailing: 0)
return section
}
...
}
- 레이아웃 적용하기
레이아웃을 적용하는 방법은, layout생성시 init을 SectionProvider를 사용하여서 적용을 해줄수 있습니다.
// CompositionalLayout(sectionProvider:)
let layout = UICollectionViewCompositionalLayout { (sectionIndex, layoutEnvironment) in
guard let section = Section(rawValue: sectionIndex) else { return nil }
switch section { }
}()
하지만 초기에 데이터가 없거나 피치못한 경우에는 collectionView의 init시점에 compostional layout을 변경해야할수도 있는데요. 이럴때는 setCollectionViewLayout() 메소드를 통해서 해결할 수 있습니다.
func setCollectionView() {
let layoutFactory = BenefitTabCompositionalLayoutFactory()
let layout: UICollectionViewCompositionalLayout? = UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
guard let section = self?.reactor?.currentState.sections?.model[safe: sectionIndex]?.sectionType else { return nil }
return layoutFactory.createLayout(by: section)
}
guard let layout else { return }
benefitCollectionView.dataSource = self
benefitCollectionView.setCollectionViewLayout(layout, animated: true)
}
🔥 Rxcocoa의 이벤트 감지 이슈
UICollectionViewCompositionalLayout의 orthogonalScrollingBehavior를 사용하면, RxCocoa의 스크롤 관련 이벤트 (rx.contentOffset, rx.willEndDragging 등)가 발생하지 않는 이슈가 있었습니다.
이유는 해당 옵션을 사용하게 되면 섹션별로 UIScrollView를 가지게 되는데요, 이때 Rxcocoa는 최상위 collectionView의 스크롤 이벤트만 감지하기 때문에 내부 스크롤의 이벤트를 감지하지 못하게 되는것입니다.
공식문서 : UICollectionLayoutSection.orthogonalScrollingBehavior
When this behavior is enabled, the section’s items are displayed using an orthogonal scrolling context, which behaves similarly to an embedded scroll view.
- 해당 속성이 활성화되면 섹션이 자체 스크롤 컨텍스트를 가지며, 마치 내부 UIScrollView처럼 동작한다.
이를 해결 하기 위해서는 해결방법이 두개가 있었습니다.
- 전통적인 collectionView를 사용하는것처럼 collectionView 내부에 collectionView를 삽입하여 개별 이벤트를 읽어오는 방법
- 장점항목 설명
✅ rxcocoa 이벤트의 호완성 내부 UICollectionView와의 완전한 이벤트를 수신할수 있습니다. ✅ indexPath기반의 데이터 식별 가능성 셀 안에 직접적으로 들어가는 데이터를 접근할수 있습니다. - 단점항목 설명
❌ 중첩스크롤 collectionView안 nested collectionView로 코드복잡도가 올라가고, 스크롤 충돌, 성능저하가 될수 있습니다 ❌ compostional layout의 장점 상실 handler는 Layout 수준에서 동작하므로, Reactor 등 ViewModel 접근이 제한적• orthogonalScrollingBehavior 를 통한 코드간결성이 사라집니다. ◦ 셀의 재사용과 내부 collectionView의 관리 코드가 증가하게 됩니다. |
- 장점항목 설명
- visibleItemsInvalidationHandler 를 통해서 스크롤 이벤트를 감지하는 방법
- 장점항목 설명
✅ Compositional Layout의 철학에 부합 섹션을 스크롤 가능한 하나의 단위로 만들고도, 커스터마이징이 가능 ✅ layout 전용 → 레이아웃 기반의 애니메이션, zoom, snap 등 구현에 유리 ✅ 중첩 스크롤 없음 단일 UICollectionView 내에서 구성이기 때문에 스크롤 충돌이 발생할 이슈가 없습니다. - 단점항목 설명
❌ indexPath를 직접 알기 어려움 visibleItems는 NSCollectionLayoutAttributes로 제공됨 (indexPath는 있지만 model과 연결 어려움) ❌ 모델 접근 불편 handler는 Layout 수준에서 동작하므로, Reactor 등 ViewModel 접근이 제한적 ❌ scroll 이벤트의 정확한 컨트롤 어려움 velocity, paging 방향 등을 정확히 제어하기 어려움 (Rx처럼 쉽지 않음)
- 장점항목 설명
저희 트래블월렛에서는, indexPath기반의 모델의 id값과 같은 데이터를 파이어베이스 이벤트에 적재 해야하는 이유로, 1번방법을 사용하여 collectionView내부의 collectionView가 들어가 있는 형태로 구성하게 되었습니다.
그러면 가로 스크롤을 사용할수 없나요??
그건아닙니다. compostionalLayout을 사용하더라도, 단일 섹션의, rxcocoa 이벤트를 감지할수가 있는데요.
이는 orthogonal 을 통한 스크롤 구현이 아닌 UICollectionView 자체의 scrollDirection을 바꿔주는것으로 해결할 수 있습니다. 이를 위한 옵션을 주는 방법은 크게 두가지가 있었는데요.
- collectionView 자체에 접근하여 scrollDirection을 주는법
private lazy var collectionView = UICollectionView().then {
$0.scrollDirection = .horizontal
}
- layout구성시 configuration을 통해서 scrollDirection을 주는 방법
private lazy var collectionView = UIColelctionView().then {
let config = UICollectionViewCompositionalLayoutConfiguration()
config.scrollDirection = .horizontal
let collectionViewLayout = UICollectionViewCompositionalLayout(
sectionProvider: { [weak self] (index, environment) in
guard let self,
let type = reactor.currentState.sections?[safe: indexPath.section]
else { return nil }
return self.sections[index].createdLayout(by: type)
},
configuration: config
)
}
이러한 방법으로 저희는 필수적으로 가로가 들어가는 섹션에 대해서는 nested UICollectionView를 통해서 문제를 해결 하였습니다.
🙌 얻은점
메모리 사용량 개선
이번 혜택탭 개선을 변경한 뒤, 기존의 CollectionView안의 CollectionView가 중첩된 형태 였는데요. 이것을 걷어내면서, 메모리가 얼마나 개선되었는지 알아보기 위해서 버전 3.0.0과 3.3.0을 비교해 보았습니다.
테스트 환경 Device - iPhone15
- 혜택 탭 진입: 3.0.0 대비 약 25.3% 감소
- 스크롤 시 : 3.0.0 대비 약 14% 감소
마치며
또한 성능면으로 앱 사용자들이 체감하기에는 미미할순 있지만, 명확하게 개선된 것도 확인할수 있었습니다.
추후에는 diffable datasource를 도입하여, DataSource의 source of truth가 명확하게 관리되는 중앙화된 datasource를 사용해서 한번더 개선을 할 예정입니다! Composable Layout의 적용기를 마치며, compositional Layout을 도입하려는 분들에게 도움이 되기를 바랍니다.
감사합니다.
참고자료