본문 바로가기
iOS/UI

iOS - Compositional Layout을 적용해 보자!

by 바등쪼 2023. 8. 3.

목표 UI

최근 진행중인 프로젝트에서 제가 맡은 뷰의 UI입니다.

상하 스크롤이 가능해야 하고 가운데에 있는 공식 홈페이지~크루 버튼 부분은 가로 스크롤이 가능해야 했습니다.

 

이 뷰를 구현하는 방법은 다양합니다.

  1. UIScrollView로 만들고 그 안에 여러 버튼들을 직접 전부 구현해서 넣기
  2. UICollectionView 또는 UITableView로 만들고 Cell로 내부 UI들 처리 + 가로 스크롤이 필요한 부분은 Cell안에 CollectionView추가
  3. UICollectionViewComposionalLayout 사용

저는 3번인 CompositionalLayout을 사용하는 것으로 정했습니다.

그 이유는 ComposotionalLayout이 이번 예시처럼 스크롤 영역이 중첩되는 뷰를 구현할 때 매우 유리하기 때문입니다.

UICollectionViewComposionalLayout에 대한 더욱 상세한 내용은 WWDC19 Advances in CollectionView Layout을 참고해주세요!

공식 문서

 

WWDC 영상에서도 소개하는 예시가 바로 앱 스토어입니다. 애플의 앱 스토어의 뷰를 보면 기본적으로는 Vertical 스크롤이 가능하고 각 섹션에서 Horizontal 스크롤도 허용하고 있습니다.

이렇게 중첩된 구조에서 Compositional Layout을 사용하면 효과적입니다.

제가 만들어야 했던 뷰도 앱 스토어와 유사한 구조입니다.

 

설계

이렇게 4개의 Section으로 나누고 2개의 SectionHeader를 포함하도록 설계했습니다.

Section 2는 가로 스크롤이 가능해야 합니다.

 

 

구현

1. VC에 CollectionView 구현

CompositionalLayout역시 CollectionView의 레이아웃이기 때문에 CollectionView를 추가하고 Constraints를 설정해줘야 합니다.

 

import UIKit
import SnapKit

public class MainVC: UIViewController {
	private lazy var collectionView: UICollectionView = {
        let cv = UICollectionView(frame: .zero, collectionViewLayout: self.createLayout())
        cv.isScrollEnabled = true
        cv.showsHorizontalScrollIndicator = false
        cv.showsVerticalScrollIndicator = false
        cv.contentInset = UIEdgeInsets(top: 7, left: 0, bottom: 0, right: 0)
        cv.backgroundColor = .clear
        return cv
    }()
    
    //... 생략
    public override func viewDidLoad() {
        super.viewDidLoad()
        self.setUI()
        self.setLayout()
        self.setDelegate()
        self.registerCells()
        // ...
    }
    
    private func setLayout() {
        view.addSubviews(naviBar, collectionView)
        
        naviBar.snp.makeConstraints { make in
            make.leading.top.trailing.equalTo(view.safeAreaLayoutGuide)
        }
        
        collectionView.snp.makeConstraints { make in
            make.top.equalTo(naviBar.snp.bottom).offset(7)
            make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide)
        }
    }
    
    private func setDelegate() {
        self.collectionView.delegate = self
        self.collectionView.dataSource = self
    }
    
    private func registerCells() {
        self.collectionView.register(UserHistoryHeaderView.self,
                                     forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
                                     withReuseIdentifier: UserHistoryHeaderView.className)
        self.collectionView.register(UserHistoryCVC.self, forCellWithReuseIdentifier: UserHistoryCVC.className)
        self.collectionView.register(BriefNoticeCVC.self, forCellWithReuseIdentifier: BriefNoticeCVC.className)
        self.collectionView.register(MainServiceCVC.self, forCellWithReuseIdentifier: MainServiceCVC.className)
        self.collectionView.register(AppServiceHeaderView.self,
                                     forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
                                     withReuseIdentifier: AppServiceHeaderView.className)
        self.collectionView.register(AppServiceCVC.self, forCellWithReuseIdentifier: AppServiceCVC.className)
    }
}

기존에 UICollectionViewFlowLayout을 사용했던 것처럼 동일하게 콜렉션뷰를 만들고 Snapkit을 사용해 위치를 잡았습니다.

 

차이점은

let cv = UICollectionView(frame: .zero, collectionViewLayout: self.createLayout())

부분입니다.

 

collectionViewLayout의 아규먼트로 UICollectionViewCompositionalLayout 타입을 넣어야 하기 때문에 createLayout 함수를 만들고 이 함수에서 레이아웃을 만들도록 했습니다. (이 함수에 대한 구현 설명도 이어집니다.)

 

registerCells()의 내부를 보면 다양한 Cell들을 등록하고 있습니다. 각 Cell들의 구현은 이 포스팅에서는 다루지 않겠습니다. (평범한 UICollectionViewCell입니다.)

 

2. VC에 UICollectionViewDelegate와 UICollectionViewDataSource 채택

extension MainVC: UICollectionViewDelegate {
}

extension MainVC: UICollectionViewDataSource {
    public func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 4
    }
    
    public func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
		// 생략
    }
    
    public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
		// 생략
    }
    
    public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
		// 생략
    }
}

각 함수들의 구현 내용은 이 포스팅에서는 생략했습니다.

구현 코드는 링크에 있습니다!

 

 

3. 각 섹션을 종류를 의미하는 열거형(enum) 생성

enum MainViewSectionLayoutKind: Int, CaseIterable {
    case userHistory
    case mainService
    case otherService
    case appService
    
    static func type(_ index: Int) -> MainViewSectionLayoutKind? {
        return self.allCases[safe: index]
    }
}

CompositionalLayout은 Section, Group, Item으로 구성됩니다.

여기서 섹션을 구분하기 위해 enum을 사용하여 구조화했습니다.

 

 

 

4. createLayout 함수 구현

extension MainVC {
    
    private enum Metric {
        static let collectionViewDefaultSideInset: Double = 20
        static let defaultItemSpacing: Double = 12
        static let defaultLineSpacing: Double = 12
    }
    
    func createLayout() -> UICollectionViewCompositionalLayout {
        return UICollectionViewCompositionalLayout { sectionIndex, env in
            guard let sectionKind = MainViewSectionLayoutKind.type(sectionIndex)
            else { return self.createEmptySection() }
            switch sectionKind {
            case .userHistory: return self.createUserInfoSection()
            case .mainService: return self.createMainServiceSection()
            case .otherService: return self.createOtherServiceSection()
            case .appService: return self.createAppServiceSection()
            }
        }
    }
    
    private func createUserInfoSection() -> NSCollectionLayoutSection {
        let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(60))
        let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
        
        let historyItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(24))
        let historyItem = NSCollectionLayoutItem(layoutSize: historyItemSize)
        
        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(72))
        let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [historyItem])
        
        let section = NSCollectionLayoutSection(group: group)
        section.boundarySupplementaryItems = [header]
        section.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: Metric.collectionViewDefaultSideInset, bottom: 16, trailing: Metric.collectionViewDefaultSideInset)
        return section
    }
    
    private func createMainServiceSection() -> NSCollectionLayoutSection {
        let topItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(25))
        let topItem = NSCollectionLayoutItem(layoutSize: topItemSize)
        
        let leadingItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), heightDimension: .fractionalHeight(1.0))
        let leadingItem = NSCollectionLayoutItem(layoutSize: leadingItemSize)
        
        leadingItem.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 6)
        
        let trailingItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(0.5))
        let trailingItem = NSCollectionLayoutItem(layoutSize: trailingItemSize)
        
        let trailingGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), heightDimension: .fractionalHeight(1.0))
        let trailingGroup = NSCollectionLayoutGroup.vertical(layoutSize: trailingGroupSize, repeatingSubitem: trailingItem, count: 2)
        trailingGroup.interItemSpacing = .fixed(Metric.defaultItemSpacing)
        trailingGroup.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 6, bottom: 0, trailing: 0)
        
        let horizontalGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(192))
        let horizontalGroup = NSCollectionLayoutGroup.horizontal(layoutSize: horizontalGroupSize, subitems: [leadingItem, trailingGroup])

        let containerGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(237))
        let containerGroup = NSCollectionLayoutGroup.vertical(layoutSize: containerGroupSize, subitems: [topItem, horizontalGroup])
        containerGroup.interItemSpacing = .fixed(Metric.defaultItemSpacing)
        
        let section = NSCollectionLayoutSection(group: containerGroup)
        section.contentInsets = NSDirectionalEdgeInsets(top: 16, leading: Metric.collectionViewDefaultSideInset, bottom: 12, trailing: Metric.collectionViewDefaultSideInset)
        
        return section
    }
    
    private func createOtherServiceSection() -> NSCollectionLayoutSection {
        let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(218), heightDimension: .fractionalHeight(1.0))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        
        let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(230), heightDimension: .absolute(90))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
        
        let section = NSCollectionLayoutSection(group: group)
        section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: Metric.collectionViewDefaultSideInset, bottom: 32, trailing: 0)
        section.orthogonalScrollingBehavior = .groupPaging
        
        return section
    }
    
    private func createAppServiceSection() -> NSCollectionLayoutSection {
        let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(44))
        let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
        
        let sideInset = Metric.collectionViewDefaultSideInset
        let itemSpacing: Double = Metric.defaultItemSpacing
        let itemWidth = (UIScreen.main.bounds.width - sideInset*2 - itemSpacing) / 2
        
        let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(itemWidth), heightDimension: .fractionalHeight(1.0))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        
        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(120))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
        group.interItemSpacing = .fixed(itemSpacing)

        let section = NSCollectionLayoutSection(group: group)
        section.contentInsets = NSDirectionalEdgeInsets(top: 14, leading: Metric.collectionViewDefaultSideInset, bottom: 0, trailing: Metric.collectionViewDefaultSideInset)
        section.boundarySupplementaryItems = [header]
        section.interGroupSpacing = Metric.defaultLineSpacing
        
        return section
    }
    
    private func createEmptySection() -> NSCollectionLayoutSection {
        NSCollectionLayoutSection(group: NSCollectionLayoutGroup(layoutSize: .init(widthDimension: .absolute(0), heightDimension: .absolute(0))))
    }
}

2번에서 만든 MainViewSectionLayoutKind 열거형을 활용하여 n번째 Section에 필요한 LayoutSection을 생성하는 함수를 분리했습니다.

 

각 create 함수는 다음과 같은 구조로 구성되어 있습니다.

  1. Item Size 생성
  2. Item 생성
  3. Group Size 생성
  4. Group 생성
  5. Section 생성 및 Group 추가

 

header를 만들 때에는 NSCollectionLayoutBoundarySupplementaryItem을 사용하여 생성하고

section.boundarySupplementaryItems = [header]를 통해 추가해주면 됩니다!

 

가로 스크롤이 필요한 OtherServiceSection에서는

section.orthogonalScrollingBehavior = .groupPaging 을 넣어서 스크롤이 가능하도록 했습니다.

단 1줄의 코드로 중첩 스크롤 기능을 넣을 수 있다는 점이 Compositional Layout의 큰 장점인 것 같습니다!!

 

groupPaging 뿐 아니라 다양한 스크롤 액션을 제공하고 있으니 링크를 참고해주세요!

 

 

 

결과

시뮬레이터 실행 화면

목표했던 화면대로 잘 동작하는 것을 확인했습니다..!

 

애플에서 CompositionalLayout을 공개하기 전까지는 중첩 스크롤 구조의 뷰를 구현하는 것이 꽤 까다로웠습니다.

Cell안에 또 다시 CollectionView나 TableView를 만들어야 했고 이로 인해 서버로부터 받은 데이터를 전달할 때 거쳐가는 객체의 Depth도 깊어졌습니다.

 

하지만! CompositionalLayout을 사용하여 하나의 CollectionView에서 다양한 구조의 뷰를 flexible하게 구현할 수 있었습니다!

앞으로도 자주 사용하게 될 방식이기 때문에 이번에 적용하면서 좋은 경험을 했던 것 같습니다!

 

더 자세한 코드는 아래의 링크(PR)에서 확인하실 수 있습니다!

https://github.com/sopt-makers/SOPT-iOS/pull/138

 

[Feat] #134 - 메인뷰 UI by lsj8706 · Pull Request #138 · sopt-makers/SOPT-iOS

🌴 PR 요약 🌱 작업한 브랜치 feature/#134 🌱 PR Point 솝탬프를 개발할 때 만들어둔 UI Component 중에 솝탬프에만 사용될 것 같은 것들 이름 앞에 ST를 붙여서 파일명이 겹치지 않도록 했습니다. 이것

github.com

 

댓글