iOS

[iOS] Compositional Layout 으로 복잡한 CollectionView 구현 - TVING 메인 뷰

띵지니어 2024. 5. 6. 21:46

안녕하세요 띵지니어 😼 입니다.
오늘은 "Compositional Layout" 에 대해 포스팅해보려고 합니다.
 

1. UICollectionViewCompositionLayout 도입부

 

UICollectionViewCompositionLayout 이 뭐지??

 

먼저 UICollectionViewCompositionLayout UICollectionViewLayout 을 상속받은 클래스입니다.
일단 개념은
flexible 하고 adaptive 한 시각적 배열로 항목을 결합할 수 있는 레이아웃 객체 라고 합니다.
(A layout object that lets you combine items in highly adaptive and flexible visual arrangements.)


그래서 어디에 쓰는데..?
앱스토어

실제로 앱스토어 보면 화면이 복잡한 걸 알 수 있습니다.
앱스토어 같은 경우에는 전체 컬렉션뷰 셀 안에 또 다른 컬렉션뷰를 넣어주어야 합니다!!
실제로 저는 아래와 같은 TVING 뷰를 만들 때, 일반 UICollectionView 로 구현하였습니다.

ViewController에
큰 verticalCell을 넣고
case마다 horizonCell을 넣어주는 방식으로 구현하였습니다.
2중 CollectionView 라고 생각하면 됩니다.
이렇게 했을 때 단점이 있었습니다.

1. 파일이 많아서 관리하기 어렵다.
2. Cell에서는 뷰이동을 할 수 없기 때문에 이벤트 처리가 복잡해진다
    (RxSwift, Combine을 쓰지 않을 시 Delegate 패턴 2번 사용해야 했다..)

실제로 위처럼 구현했을 시 이벤트를 싱글톤 객체 + Combine으로 넘겨주는 방식으로 진행했습니다.

코드 몰라도 됩니다.

저는 이런 문제를 해결하기 위해 CompositionalLayout을 도입하였습니다.


 

2. UICollectionViewCompositionLayout 개념

 

UICollectionViewCompositionLayout

본격 적으로 CompositionalLayout에 대해 알아볼게요

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

 
CompositionalLayout은 1개 이상의 Section으로 구성됩니다.
Section은 표시하려는 데이터의 Group으로 구성됩니다.
GroupItem을 가로 행, 세로 열 또는 사용자 정의 배열로 배치할 수 있습니다.

따라서 compositional layout의 계층은
Layout > Section > Group > Item 
순으로 이루어져 있습니다.

Item은 표시하려는 데이터의 가장 작은 단위입니다.
각 속성들을 하나씩 간단하게 알아보겠습니다.
 

Section
1. Section - NSCollectionLayoutSection

Group을 담는 컨테이너.

  • CollectionView는 하나 또는 여러 개의 Section을 가질 수 있습니다.
  • Section은 NSCollectionLayoutGroup에 의해 결정됩니다.
  • 각 Section은 고유의 배경, Header, Footer를 가질 수 있습니다.

 

Group
2. Group - NSCollectionLayoutGroup

Item을 담는 컨테이너.

  • Item을 특정 Path에 따라 배치하는 역할을 합니다.
  • Group 자체는 레이아웃만 배치하고 렌더링은 하지 않습니다.
  • horizontal, vertical, custom 메서드를 가지고 있습니다.

 

Item
3. Item

CollectionView의 가장 기본 컴포넌트입니다.

  • Item은 크기, 개별 content의 size, space, arrange를 어떻게 할지에 대한 blueprint입니다.
  • 일반적으로 Item은 Cell이지만 Headers, Footers, Decorations와 같은 Supplementary View도 될 수 있습니다.

다음은 Layout 을 알아보겠습니다.

NSCollectionLayoutDimension

 

1. fractionalWidth & fractionalHeight
현재 자신이 속한 컨테이너의 크기를 기반으로 비율로써 자신의 크기를 정합니다. (0.0~1.0 사이의 CGFloat값을 넣을 수 있습니다.

예를 들어 fractionalWidth(1.0), fractionalHeight(1.0) 으로 지정을 하면
item의 컨테이너는 Group 이기 때문에 Group 크기의 100% 라고 생각하면 됩니다.

let itemSize = NSCollectionLayoutSize(
    widthDimension: .fractionalWidth(1.0),
    heightDimension: .fractionalHeight(1.0)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)

item의 widthDimension 이 .fractionalWidth(0.2) 라면
item은 Group 크기의 20%를 나타냅니다.

 

2. absolute
절대 크기, 항상 고정된 크기로 나타 냅니다.
let groupSize = NSCollectionLayoutSize(
    widthDimension: .fractionalWidth(1.0),
    heightDimension: .absolute(44)
)
let group = NSCollectionLayoutGroup.horizontal(
    layoutSize: groupSize,
    subitems: [item]
)

예를 들어 heightDimension 이 .absolute(44) 이면 컨테이너에 크기에 상관없이 절댓값인 44 point가 지정이 됩니다.

 

3. estimated
런타임에 크기가 변할 가능성이 있는 경우(시스템의 글꼴 크기 변경과 같은) estimated를 사용합니다.
이는 시스템이 예상 크기를 기반으로 실제 크기가 계산됩니다.
let estimatedSize = NSCollectionLayoutSize(
    widthDimension: .estimated(200),
    heightDimension: .estimated(100)
)

 

3. UICollectionViewCompositionLayout 실습

 

적용해 보기

과거 티빙 메인 화면

실제로 과거 TVING 메인화면 UI를 구현하면 다음과 같이 구현할 수 있습니다.
총 6개의 Section이 존재하고, 그 안에 Group과 또 그 안에 Item들이 존재합니다.
실제로 Compositional로 구현하면 아래와 같습니다.

 

아래는 위의 뷰를 UICollectionViewCompositionalLayout으로  구현 한 코드입니다.

//
//  CompositionalLayout.swift
//  tvingProject
//
//  Created by 이명진 on 4/24/24.
//

import UIKit

struct CompositionalLayout {
    
    static func createLayout() -> UICollectionViewCompositionalLayout {
        return UICollectionViewCompositionalLayout { (sectionNumber, _) -> NSCollectionLayoutSection? in
            
            if sectionNumber == 0 {
                let item = NSCollectionLayoutItem(
                    layoutSize: .init(
                        widthDimension: .fractionalWidth(1),
                        heightDimension: .fractionalHeight(1)
                    )
                )
                
                let group = NSCollectionLayoutGroup.horizontal(
                    layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(498)),
                    subitems: [item]
                )
                
                let section = NSCollectionLayoutSection(group: group)
                
                section.orthogonalScrollingBehavior = .paging

                return section
                
            } else if sectionNumber == 1 || sectionNumber == 5 {
                
                let item = NSCollectionLayoutItem(
                    layoutSize: .init(
                        widthDimension: .fractionalWidth(1),
                        heightDimension: .fractionalHeight(1)
                    )
                )
                
                item.contentInsets.leading = 8
                
                let group = NSCollectionLayoutGroup.horizontal(
                    layoutSize: .init(widthDimension: .fractionalWidth(0.3), heightDimension: .absolute(146)),
                    subitems: [item]
                )
                
                let section = NSCollectionLayoutSection(group: group)
                
                section.orthogonalScrollingBehavior = .continuous
                
                let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(60))
                let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
                section.boundarySupplementaryItems = [header]
                
                return section
            } else if sectionNumber == 2 {
                let item = NSCollectionLayoutItem(
                    layoutSize: .init(
                        widthDimension: .fractionalWidth(0.5),
                        heightDimension: .fractionalHeight(1)
                    )
                )
                
                item.contentInsets.trailing = 8
                
                let group = NSCollectionLayoutGroup.horizontal(
                    layoutSize: .init(widthDimension: .fractionalWidth(0.9), heightDimension: .absolute(140)),
                    subitems: [item]
                )
                
                
                let section = NSCollectionLayoutSection(group: group)
                
                section.orthogonalScrollingBehavior = .continuous
                
                let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(60))
                let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
                section.boundarySupplementaryItems = [header]
                
                return section
            } else if sectionNumber == 3 {
                let item = NSCollectionLayoutItem(
                    layoutSize: .init(
                        widthDimension: .fractionalWidth(1),
                        heightDimension: .fractionalHeight(1)
                    )
                )
                
                item.contentInsets.leading = 8
                
                let group = NSCollectionLayoutGroup.horizontal(
                    layoutSize: .init(widthDimension: .fractionalWidth(0.3), heightDimension: .absolute(164)),
                    subitems: [item]
                )
                
                let section = NSCollectionLayoutSection(group: group)
                
                section.orthogonalScrollingBehavior = .continuous
                
                let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(60))
                let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
                section.boundarySupplementaryItems = [header]
                
                return section
                
            } else if sectionNumber == 4 { // 광고
                let item = NSCollectionLayoutItem(
                    layoutSize: .init(
                        widthDimension: .fractionalWidth(0.5),
                        heightDimension: .fractionalHeight(1)
                    )
                )
                
                item.contentInsets.trailing = 8
                
                let group = NSCollectionLayoutGroup.horizontal(
                    layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(58)),
                    subitems: [item]
                )

                let section = NSCollectionLayoutSection(group: group)
                
                section.orthogonalScrollingBehavior = .continuous
                section.contentInsets.top = 48
                
                return section
            } else {
                
                let item = NSCollectionLayoutItem(
                    layoutSize: .init(
                        widthDimension: .fractionalWidth(1),
                        heightDimension: .fractionalHeight(1)
                    )
                )
                item.contentInsets.bottom = 44
                
                let group = NSCollectionLayoutGroup.horizontal(
                    layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(498)),
                    subitems: [item]
                )
                
                let section = NSCollectionLayoutSection(group: group)
                
                section.orthogonalScrollingBehavior = .paging
                
                let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(100))
                let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
                section.boundarySupplementaryItems = [header]
                
                return section
            }
        }
    }
}



// 사용하는 곳에서 아래 처럼 사용

lazy var homeCollectionView: UICollectionView = {
    let layout = CompositionalLayout.createLayout()

    let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
    collectionView.backgroundColor = .clear
    collectionView.isScrollEnabled = true
    return collectionView
}()

 

처음 함수 시작이 될 때 리턴 타입인 UICollectionViewCompositionalLayout
sectionNumber 라는 클로저 파라미터가 존재하는 걸 알 수가 있습니다.

static func createLayout() -> UICollectionViewCompositionalLayout {
        return UICollectionViewCompositionalLayout { (sectionNumber, _) -> NSCollectionLayoutSection? in
            
            if sectionNumber == 0 {

Compositional 생성자는 다음과 같습니다.

하지만 UICollectionViewCompositionalLayoutSectionProvider 이 타입은 아래처럼 typealias로 되어 있는 걸 확인할 수 있다.
 

따라서 Int 로 되어있는 클로저 파라미터를 받아, SectionNumber마다 어떤 Section을 가지는지 지정해 줄 수 있습니다.


 

4. UICollectionViewCompositionLayout 헤더 & 푸터

 

헤더가 있는 레이아웃

이제 위에 코드에서 일부 코드를 보면 아래와 같이 되어있는 걸 볼 수 있는데

 
순서대로, item, group, section, (Header) 코드입니다.
여기서 새롭게 보이는 건 Header 가 새로 보일 텐데
해당 CompositionalLayout도 Header와 Footer를 따로 코드로 레이아웃을 잡을 수 있기 때문입니다.
section 1과 5번의 UI는 다음과 같습니다.

둘 다 보면 헤더가 존재하는 걸 알 수 있습니다.


 
마찬가지로 비슷하게 푸터도 아래처럼 구현할 수 있습니다.

여기까지는 Data 등록을 하지 않고, 레이아웃만 잡는 작업만 진행하였습니다.

 

이제 Section에 맞게 레이아웃을 구현했으니
DataSource와 같은 로직을 ViewController에서 따로 등록 하면 됩니다.


5. UICollectionViewCompositionLayout 에 Cell 등록

 

레이아웃에 각각 Cell 바인딩하기
(데이터 등록)

그전에 
enum으로 가볍게 각 case를 나눠줬습니다.

main과, required 케이스는 8개의 item
나머지는 4개의 item을 같게 계산 속성을 잡아주었습니다.

1. 델리게이트 등록과 Cell 등록을 먼저 해줍니다.

 

2. cellForItemAt에서 Section 마다 Cell을 등록합니다.

 

3. numberOfSections 메서드에서 Section의 개수를 등록하고,
numberOfItemsInSection 메서드에서 Section에 몇 개의 item이 들어가는지 구현해 줍니다.

 

4. viewForSupplementaryElementOfKind 에서 HeaderFooter를 등록해 줍니다.

이렇게 데이터까지 바인딩을 해준다면 아래와 같이 최종적으로 복잡한 레이아웃을 구현할 수 있습니다.

정리

파일이 많아지고, 코드가 복잡해지는 문제를 해결하기 위해 CompositionalLayout을 도입 해봤습니다.

장점은 Layout 코드를 하나의 파일로 분리하여 코드의 가독성을 높일 수 있었습니다.
Header와 Footer까지 구현을 하다 보니 평소 Section 사이에 뷰를 넣으면서 구현했지만 이번 기회로 확실하게 NSCollectionLayoutBoundarySupplementaryItem 사용할 것 같습니다.

또한 적은 파일로 복잡한 뷰를 구현할 수 있었습니다. 그만큼 메모리 절약도 있었습니다.!

마무리를 하면서.. 아직 파일 정리를 하지 못해서 좋은 아키텍처라고는 볼 수 없습니다...!
오히려 읽는 사람이 어떤 구조인지 이해 못 할 수도 있기 때문에 더 잘 작성하는 방법을 연구해야겠습니다..
Layout 구현 부에서 Section == 0... 이런 식으로 구현이 되어 있을 텐데,
같은 Layout이 아니라면 각 Section 끼리 함수로 묶는다면 단일 책임 원칙에 위배하지 않는 코드가 될 것이라고 생각합니다.

단점은 숙련도가 부족하면 오히려 더 어려울 수 있다는 생각이 들었습니다.
처음부터 CollectionViewFlowLayout이 아닌 CompositionalLayout로 구현한다면 어디서 오류가 발생하는지 찾기 어려울 것 같고
아무래도 레이아웃이 겹치는 곳이 발생하다 보니 중복되는 레이아웃이 있을 수 있을 거라고 생각합니다.
중복되는 레이아웃이 있다면 오히려 그만큼 메모리를 더 사용한다는 뜻이니 주의해야겠습니다.
 
자세한건 GitHub 코드에서 볼 수 있습니다.

 

LeeMyeongJin-assignment/tvingProject/tvingProject/Resource/Utils/CompositionalLayout.swift at main · NOW-SOPT-iOS-Part/LeeMyeon

Contribute to NOW-SOPT-iOS-Part/LeeMyeongJin-assignment development by creating an account on GitHub.

github.com

Xcode 15.1
iOS 17.4.1
MacOS Sonoma 14.2.1
환경에서 작성 한 글입니다.