[UIKit] UICollectionView: Compositional Layout & Sections

Junyoung Park·2022년 12월 3일
1

UIKit

목록 보기
107/142
post-thumbnail

UICollectionView: Compositional Layout & Sections

UICollectionView: Compositional Layout & Sections

구현 목표

  • 컴포지셔널 레이아웃을 사용한 컬렉션 뷰가 여러 개의 섹션을 가지고 있을 때 UI 구현

구현 태스크

  • 컴포지녀설 레이아웃 구현
  • 섹션 별 서로 다른 UI 구현

핵심 코드

private lazy var collectionView: UICollectionView = {
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout())
        collectionView.delegate = self
        collectionView.dataSource = self
        collectionView.register(StoryCollectionViewCell.self, forCellWithReuseIdentifier: StoryCollectionViewCell.identifier)
        collectionView.register(PortraitCollectionViewCell.self, forCellWithReuseIdentifier: PortraitCollectionViewCell.identifier)
        collectionView.register(LandscapeCollectionViewCell.self, forCellWithReuseIdentifier: LandscapeCollectionViewCell.identifier)
        collectionView.register(CollectionViewHeaderReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: CollectionViewHeaderReusableView.identifier)
        return collectionView
    }()
  • 뷰 컨트롤러에 사용할 컬렉션 뷰를 코드로 구현한 부분
  • lazy var을 통해 선언 이후 델리게이트와 데이터 소스에 self를 줄 수 있음
  • 컬렉션 뷰에 사용할 셀 클래스 및 재사용 클래스(헤더 등) 등록 주의
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let sections = viewModel.pageData.value
        switch sections[indexPath.section] {
        case .stories(let models):
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: StoryCollectionViewCell.identifier, for: indexPath) as? StoryCollectionViewCell else { fatalError() }
            cell.configure(with: models[indexPath.row])
            return cell
        case .popular(let models):
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PortraitCollectionViewCell.identifier, for: indexPath) as? PortraitCollectionViewCell else { fatalError() }
            cell.configire(with: models[indexPath.row])
            return cell
        case .comingSoon(let models):
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: LandscapeCollectionViewCell.identifier, for: indexPath) as? LandscapeCollectionViewCell else { fatalError() }
            cell.configire(with: models[indexPath.row])
            return cell
        }
    }
  • 뷰 모델이 가지고 있는 데이터 sections을 통해 어떤 종류의 섹션인지 확인 가능
  • 인덱스 패스의 row를 통해 해당 섹션 내 어떤 아이템을 넣어야 하는지 확인
  • guard let 바인딩을 통해 주어진 섹션에 알맞은 셀 클래스를 얻어낸 뒤 커스텀 함수를 사용
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        switch kind {
        case UICollectionView.elementKindSectionHeader:
            guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: CollectionViewHeaderReusableView.identifier, for: indexPath) as? CollectionViewHeaderReusableView else { fatalError() }
            header.configure(with: viewModel.pageData.value[indexPath.section].title)
            return header
        default: return UICollectionReusableView()
        }
    }
  • 커스텀 셀 클래스를 사용하는 로직과 상동
private func createLayout() -> UICollectionViewCompositionalLayout {
        UICollectionViewCompositionalLayout { [weak self] sectionIndex, layoutEnvironment in
            guard let self = self else { return nil }
            let section = self.viewModel.pageData.value[sectionIndex]
            switch section {
            case .stories:
                let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
                let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .absolute(70), heightDimension: .absolute(70)), subitems: [item])
                let section = NSCollectionLayoutSection(group: group)
                section.orthogonalScrollingBehavior = .continuous
                section.interGroupSpacing = 10
                section.contentInsets = .init(top: 0, leading: 10, bottom: 30, trailing: 10)
                section.boundarySupplementaryItems = [self.createSupplementaryHeaderItem()]
                section.supplementaryContentInsetsReference = .layoutMargins
                return section
            case .popular:
                let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
                let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(0.9), heightDimension: .fractionalHeight(0.6)), subitems: [item])
                let section = NSCollectionLayoutSection(group: group)
                section.orthogonalScrollingBehavior = .groupPaging
                section.interGroupSpacing = 10
                section.contentInsets = .init(top: 0, leading: 10, bottom: 30, trailing: 10)
                section.boundarySupplementaryItems = [self.createSupplementaryHeaderItem()]
                section.supplementaryContentInsetsReference = .layoutMargins
                return section
            case .comingSoon:
                let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
                let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .absolute(170), heightDimension: .absolute(80)), subitems: [item])
                let section = NSCollectionLayoutSection(group: group)
                section.orthogonalScrollingBehavior = .groupPaging
                section.interGroupSpacing = 10
                section.contentInsets = .init(top: 0, leading: 10, bottom: 0, trailing: 10)
                section.boundarySupplementaryItems = [self.createSupplementaryHeaderItem()]
                section.supplementaryContentInsetsReference = .layoutMargins
                return section
            }
        }
    }
    
  • 컴포지셔널 레이아웃을 리턴, 컬렉션 뷰를 이니셜라이즈하는 부분
  • 주어진 섹션에 따라 서로 다른 종류의 레이아웃을 구성 가능하다는 점이 특징
  • 아이템, 그룹, 섹션 세 개 종류의 타입으로 이루어진 레이아웃
  • 각 아이템이 주어진 그룹 내에서 어느 정도의 크기로 존재할 것인지 결정
  • 각 그룹이 해당 어떤 크기로 존재할 것인지 비율 또는 절대값을 통해 설정 가능
  • 그룹 간의 간격, 패딩, 추가적인 아이템(즉 컬렉션 뷰 헤더 아이템 등), 스크롤 방향, 스크롤 방법 등 상세한 내용을 커스텀 가능
  • supplementaryContentInsetsReference는 iOS 16부터 도입된 프로퍼티로 해당 아이템이 어떤 위치에 존재할 지 결정 가능
private func createSupplementaryHeaderItem() -> NSCollectionLayoutBoundarySupplementaryItem {
        return NSCollectionLayoutBoundarySupplementaryItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .estimated(50)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
    }
  • 섹션을 구성하는 헤더 아이템을 리턴하는 코드

소스 코드

import UIKit
import Combine

class CollectionViewController: UIViewController {
    private lazy var collectionView: UICollectionView = {
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout())
        collectionView.delegate = self
        collectionView.dataSource = self
        collectionView.register(StoryCollectionViewCell.self, forCellWithReuseIdentifier: StoryCollectionViewCell.identifier)
        collectionView.register(PortraitCollectionViewCell.self, forCellWithReuseIdentifier: PortraitCollectionViewCell.identifier)
        collectionView.register(LandscapeCollectionViewCell.self, forCellWithReuseIdentifier: LandscapeCollectionViewCell.identifier)
        collectionView.register(CollectionViewHeaderReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: CollectionViewHeaderReusableView.identifier)
        return collectionView
    }()
    private let viewModel = CollectionViewModel()
    private var cancellables = Set<AnyCancellable>()
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        collectionView.frame = view.bounds
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        setUI()
        bind()
    }
    
    private func setUI() {
        view.backgroundColor = .systemBackground
        view.addSubview(collectionView)
    }
    
    private func bind() {
        viewModel
            .pageData
            .sink { [weak self] _ in
                self?.collectionView.reloadData()
            }
            .store(in: &cancellables)
    }
    
    private func createLayout() -> UICollectionViewCompositionalLayout {
        UICollectionViewCompositionalLayout { [weak self] sectionIndex, layoutEnvironment in
            guard let self = self else { return nil }
            let section = self.viewModel.pageData.value[sectionIndex]
            switch section {
            case .stories:
                let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
                let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .absolute(70), heightDimension: .absolute(70)), subitems: [item])
                let section = NSCollectionLayoutSection(group: group)
                section.orthogonalScrollingBehavior = .continuous
                section.interGroupSpacing = 10
                section.contentInsets = .init(top: 0, leading: 10, bottom: 30, trailing: 10)
                section.boundarySupplementaryItems = [self.createSupplementaryHeaderItem()]
                section.supplementaryContentInsetsReference = .layoutMargins
                return section
            case .popular:
                let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
                let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .fractionalWidth(0.9), heightDimension: .fractionalHeight(0.6)), subitems: [item])
                let section = NSCollectionLayoutSection(group: group)
                section.orthogonalScrollingBehavior = .groupPaging
                section.interGroupSpacing = 10
                section.contentInsets = .init(top: 0, leading: 10, bottom: 30, trailing: 10)
                section.boundarySupplementaryItems = [self.createSupplementaryHeaderItem()]
                section.supplementaryContentInsetsReference = .layoutMargins
                return section
            case .comingSoon:
                let item = NSCollectionLayoutItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)))
                let group = NSCollectionLayoutGroup.horizontal(layoutSize: .init(widthDimension: .absolute(170), heightDimension: .absolute(80)), subitems: [item])
                let section = NSCollectionLayoutSection(group: group)
                section.orthogonalScrollingBehavior = .groupPaging
                section.interGroupSpacing = 10
                section.contentInsets = .init(top: 0, leading: 10, bottom: 0, trailing: 10)
                section.boundarySupplementaryItems = [self.createSupplementaryHeaderItem()]
                section.supplementaryContentInsetsReference = .layoutMargins
                return section
            }
        }
    }
    
    private func createSupplementaryHeaderItem() -> NSCollectionLayoutBoundarySupplementaryItem {
        return NSCollectionLayoutBoundarySupplementaryItem(layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .estimated(50)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
    }
}
  • 컬렉션 뷰를 그리는 뷰 컨트롤러
  • 컴바인을 통해 MVVM 스타일로 적용, 뷰 모델이 가지고 있는 데이터 퍼블리셔를 구독, 해당 값이 업데이트되었을 때 컬렉션 뷰 자체를 로드
  • 컴포지셔널 레이아웃을 통해 컬렉션 뷰를 구성, 여러 가지 종류의 섹션에 해당하는 커스텀 구성을 적용 가능
extension CollectionViewController: UICollectionViewDelegate {
    
}

extension CollectionViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return viewModel.pageData.value[section].count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let sections = viewModel.pageData.value
        switch sections[indexPath.section] {
        case .stories(let models):
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: StoryCollectionViewCell.identifier, for: indexPath) as? StoryCollectionViewCell else { fatalError() }
            cell.configure(with: models[indexPath.row])
            return cell
        case .popular(let models):
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PortraitCollectionViewCell.identifier, for: indexPath) as? PortraitCollectionViewCell else { fatalError() }
            cell.configire(with: models[indexPath.row])
            return cell
        case .comingSoon(let models):
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: LandscapeCollectionViewCell.identifier, for: indexPath) as? LandscapeCollectionViewCell else { fatalError() }
            cell.configire(with: models[indexPath.row])
            return cell
        }
    }
    
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return viewModel.pageData.value.count
    }
    
    func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        switch kind {
        case UICollectionView.elementKindSectionHeader:
            guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: CollectionViewHeaderReusableView.identifier, for: indexPath) as? CollectionViewHeaderReusableView else { fatalError() }
            header.configure(with: viewModel.pageData.value[indexPath.section].title)
            return header
        default: return UICollectionReusableView()
        }
    }
}
  • 컬렉션 뷰 내의 셀이 어떤 식으로 UI를 그려야 하는지 알려주는 데이터소스 함수
  • 섹션을 switch case로 받은 뒤 어떤 종류의 셀을 적용할지 결정 가능
import UIKit
import Combine

class CollectionViewModel {
    let pageData: CurrentValueSubject<[PhotoSection], Never> = .init([])
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        fetchData()
    }
    
    // fetchData() -> return mock data
}
  • 뷰 컨트롤러가 구독할 데이터 퍼블리셔 저장
  • 본래 네트워크 담당 API를 통해 데이터를 패치받아 해당 뷰 모델 내 데이터 소스에 저장
  • 해당 데이터에 해당하는 섹션으로 바인딩해 퍼블리셔에 넘겨주기
import Foundation

struct PhotoModel: Hashable, Codable {
    var id: String {
        return UUID().uuidString
    }
    let name: String
    let imageURLString: String
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}
  • 이름과 다운로드받을 주소값을 가진 간단한 데이터 모델
import Foundation

enum PhotoSection {
    case stories([PhotoModel])
    case popular([PhotoModel])
    case comingSoon([PhotoModel])
    
    var models: [PhotoModel] {
        switch self {
        case .stories(let models), .comingSoon(let models), .popular(let models): return models
        }
    }
    
    var count: Int {
        return models.count
    }
    
    var title: String {
        switch self {
        case .stories: return "Stories"
        case .popular: return "Popular"
        case .comingSoon: return "Coming Soon"
        }
    }
}
  • 이넘을 통해 손쉽게 관리 가능한 섹션 모델
  • 해당 섹션 별로 어떤 종류의 아이템으로 구성될지 결정 가능
  • 연산 프로퍼티를 통해 해당 섹션의 제목, 섹션 별 아이템 개수 등을 사용 가능
import UIKit

class CollectionViewHeaderReusableView: UICollectionReusableView {
    static let identifier = "CollectionViewHeaderReusableView"
    private let textLabel: UILabel = {
        let label = UILabel()
        label.font = .preferredFont(forTextStyle: .headline)
        label.textColor = .label
        label.textAlignment = .left
        label.numberOfLines = 0
        return label
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func prepareForReuse() {
        super.prepareForReuse()
        textLabel.text = nil
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        textLabel.frame = bounds
    }
    
    private func setUI() {
        addSubview(textLabel)
    }
    
    func configure(with title: String) {
        textLabel.text = title
    }
}
  • 컬렉션 뷰에 재사용할 간단한 헤더 뷰
import UIKit

class StoryCollectionViewCell: UICollectionViewCell {
    static let identifier = "StoryCollectionViewCell"
    
    private let imageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFill
        imageView.layer.masksToBounds = true
        return imageView
    }()
    override init(frame: CGRect) {
        super.init(frame: frame)
        setUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        imageView.frame = contentView.bounds
        imageView.layer.cornerRadius = imageView.frame.height / 2
    }
    
    override func prepareForReuse() {
        super.prepareForReuse()
        imageView.image = nil
    }
    
    private func setUI() {
        contentView.addSubview(imageView)
    }
    
    func configure(with model: PhotoModel) {
        guard let url = URL(string: model.imageURLString) else { return }
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            guard
                let data = data,
                let image = UIImage(data: data),
                let response = response as? HTTPURLResponse,
                response.statusCode >= 200 && response.statusCode < 400,
                error == nil else {
                return
            }
            DispatchQueue.main.async { [weak self] in
                self?.imageView.image = image
            }
        }
        .resume()
    }
}
  • 첫 번째 섹션에 해당하는 컬렉션 뷰 셀
  • 원형의 이미지 뷰를 그리기
import UIKit

class PortraitCollectionViewCell: UICollectionViewCell {
    static let identifier = "PortraitCollectionViewCell"
    private let imageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFill
        imageView.layer.masksToBounds = true
        imageView.layer.cornerRadius = 12
        return imageView
    }()
    private let textLabel: UILabel = {
        let label = UILabel()
        label.textColor = .systemBackground
        label.textAlignment = .center
        label.numberOfLines = 0
        label.font = .preferredFont(forTextStyle: .body)
        label.backgroundColor = .label
        return label
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        imageView.frame = contentView.bounds
    }
    
    override func prepareForReuse() {
        super.prepareForReuse()
        imageView.image = nil
        textLabel.text = nil
    }
    
    private func setUI() {
        contentView.addSubview(imageView)
        imageView.addSubview(textLabel)
        textLabel.translatesAutoresizingMaskIntoConstraints = false
        let textLabelConstrains = [
            textLabel.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
            textLabel.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
            textLabel.bottomAnchor.constraint(equalTo: imageView.bottomAnchor)
        ]
        NSLayoutConstraint.activate(textLabelConstrains)
    }
    
    func configire(with model: PhotoModel) {
        textLabel.text = model.name
        guard let url = URL(string: model.imageURLString) else { return }
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            guard
                let data = data,
                let image = UIImage(data: data),
                let response = response as? HTTPURLResponse,
                response.statusCode >= 200 && response.statusCode < 400,
                error == nil else {
                return
            }
            DispatchQueue.main.async { [weak self] in
                self?.imageView.image = image
            }
        }
        .resume()
    }
    
}
  • 세로 형태가 주가 될 커스텀 컬렉션 뷰 셀
  • 이미지 뷰의 서브 뷰로 텍스트 라벨을 추가, 후자는 오토 레이아웃으로 잡은 게 특징
import UIKit

class LandscapeCollectionViewCell: UICollectionViewCell {
    static let identifier = "LandscapeCollectionViewCell"
    private let imageView: UIImageView = {
        let imageView = UIImageView()
        imageView.clipsToBounds = true
        imageView.contentMode = .scaleAspectFill
        return imageView
    }()
    private let textLabel: UILabel = {
        let label = UILabel()
        label.backgroundColor = .label
        label.textColor = .systemBackground
        label.textAlignment = .center
        label.numberOfLines = 0
        label.clipsToBounds = true
        label.font = .preferredFont(forTextStyle: .body)
        return label
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func prepareForReuse() {
        super.prepareForReuse()
        imageView.image = nil
        textLabel.text = nil
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        let height = contentView.frame.size.height
        let imageWidth = contentView.frame.size.width / 3
        let textWidth = contentView.frame.size.width - imageWidth
        imageView.frame = CGRect(origin: .zero, size: .init(width: imageWidth, height: height))
        textLabel.frame = CGRect(origin: .init(x: imageWidth, y: 0), size: .init(width: textWidth, height: height))
    }
    
    private func setUI() {
        contentView.layer.cornerRadius = 12
        contentView.layer.masksToBounds = true
        contentView.addSubview(imageView)
        contentView.addSubview(textLabel)
    }
    
    func configire(with model: PhotoModel) {
        textLabel.text = model.name
        guard let url = URL(string: model.imageURLString) else { return }
        URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            guard
                let data = data,
                let image = UIImage(data: data),
                let response = response as? HTTPURLResponse,
                response.statusCode >= 200 && response.statusCode < 400,
                error == nil else {
                return
            }
            DispatchQueue.main.async { [weak self] in
                self?.imageView.image = image
            }
        }
        .resume()
    }
}
  • 세 번째 섹션을 담당할 컬렉션 뷰 커스텀 셀
  • 컨텐츠 뷰 전체가 둥근 모서리를 가지고 있는 게 특징, 각 넓이를 등분해 각 이미지 뷰와 텍스트 라벨의 크기를 결정

구현 화면

섹션 별로 다양하고 조화롭게 사용할 수 있도록 보다 익숙해지자!

profile
JUST DO IT

0개의 댓글