관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] 1. UICollectionViewCompositionalLayout - 개념 (section, group, item) 본문

iOS 응용 (swift)

[iOS - swift] 1. UICollectionViewCompositionalLayout - 개념 (section, group, item)

jake-kim 2022. 4. 19. 22:45

1. UICollectionViewCompositionalLayout - 개념 (section, group, item)

2. UICollectionViewCompositionalLayout - 개념 SupplementaryView, Header, Footer)

3. UICollectionViewCompositionalLayout - 개념 (DecorationView, Badge)

4. UICollectionViewCompositionalLayout - 개념 (orthogonalScrollingBehavior,  수평 스크롤, visibleItemsInvalidationHandler)

5. UICollectionViewCompositionalLayout - 응용 (유튜브 뮤직 앱 UI 구현)

CompositionalLayout 기본 개념

  • CompositionalLayout의 구성은 Section + Group + Item
    • 여기서 핵심은 group이며, group은 한 화면에 들어가는 item들을 묶는 단위

  • 레이아웃을 구성할 때 section, group, item의 레이아웃을 설정하여 구현
  • CompositionalLayout은 하나의 CollectionView에 섹션별로 다른 layout을 구성하기가 쉬운 장점이 존재

NSCollectionLayoutDimension

CollectionView의 item 사이즈를 정하는 방법은 3가지

  • .absolute - 고정 크기
  • .estimated - 런타임에 변경
  • .fractional - 비율
let absoluteSize = NSCollectionLayoutSize(
  widthDimension: .absolute(32),
  heightDimension: .absolute(32)
)
let estimatedSize = NSCollectionLayoutSize(
  widthDimension: .estimated(120),
  heightDimension: .estimated(120)
)
let fractionalSize = NSCollectionLayoutSize(
  widthDimension: .fractionalWidth(0.2),
  heightDimension: .fractionalHeight(0.2)
)

Section, Group, Item 사용 방법

  • collectionView, dataSource 정의
final class ViewController: UIViewController {
  private lazy var collectionView: UICollectionView = {
    let view = UICollectionView(frame: .zero, collectionViewLayout: self.compositionalLayout) // TODO: compositionalLayout
    view.isScrollEnabled = true
    view.showsHorizontalScrollIndicator = false
    view.showsVerticalScrollIndicator = true
    view.scrollIndicatorInsets = UIEdgeInsets(top: -2, left: 0, bottom: 0, right: 4)
    view.contentInset = .zero
    view.backgroundColor = .clear
    view.clipsToBounds = true
    view.register(MyCell.self, forCellWithReuseIdentifier: "MyCell") // TODO: MyCell
    view.translatesAutoresizingMaskIntoConstraints = false
    return view
  }()
  
  private let dataSource: [MySection] = [
    .first((1...30).map(String.init).map(MySection.FirstItem.init(value:))),
    .second((31...60).map(String.init).map(MySection.SecondItem.init(value:))),
  ]
  
  override func viewDidLoad() {
    super.viewDidLoad()
    self.view.addSubview(self.collectionView)
    NSLayoutConstraint.activate([
      self.collectionView.leftAnchor.constraint(equalTo: self.view.leftAnchor),
      self.collectionView.rightAnchor.constraint(equalTo: self.view.rightAnchor),
      self.collectionView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
      self.collectionView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor),
    ])
    self.collectionView.dataSource = self
  }
}

enum MySection {
  case first([FirstItem])
  case second([SecondItem])
  
  struct FirstItem {
    let value: String
  }
  struct SecondItem {
    let value: String
  }
}
  • MyCell과 dataSource 구현
extension ViewController: UICollectionViewDataSource {
  func numberOfSections(in collectionView: UICollectionView) -> Int {
    self.dataSource.count
  }
  func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
    switch self.dataSource[section] {
    case let .first(items):
      return items.count
    case let .second(items):
      return items.count
    }
  }
  func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "MyCell", for: indexPath) as! MyCell
    switch self.dataSource[indexPath.section] {
    case let .first(items):
      cell.prepare(text: items[indexPath.item].value)
    case let .second(items):
      cell.prepare(text: items[indexPath.item].value)
    }
    return cell
  }
}

final class MyCell: UICollectionViewCell {
  let label: UILabel = {
    let label = UILabel()
    label.textColor = .white
    label.translatesAutoresizingMaskIntoConstraints = false
    return label
  }()
  
  required init?(coder: NSCoder) {
    fatalError()
  }
  override init(frame: CGRect) {
    super.init(frame: frame)
    self.contentView.backgroundColor = UIColor(
      red: CGFloat(drand48()),
      green: CGFloat(drand48()),
      blue: CGFloat(drand48()),
      alpha: 1.0
    )
    self.contentView.addSubview(self.label)
    NSLayoutConstraint.activate([
      self.label.centerYAnchor.constraint(equalTo: self.contentView.centerYAnchor),
      self.label.centerXAnchor.constraint(equalTo: self.contentView.centerXAnchor),
    ])
  }
  
  override func prepareForReuse() {
    super.prepareForReuse()
    self.prepare(text: "")
  }
  
  func prepare(text: String) {
    self.label.text = text
  }
}

compositionalLayout 정의

세로로 4개의 셀이있고 가로로 3개의 셀이 있는 형태

  • Item 정의 -> Group 정의 -> Section 정의
  private let compositionalLayout: UICollectionViewCompositionalLayout = {
    let itemFractionalWidthFraction = 1.0 / 3.0 // horizontal 3개의 셀
    let groupFractionalHeightFraction = 1.0 / 4.0 // vertical 4개의 셀
    let itemInset: CGFloat = 2.5
    
    // Item
    let itemSize = NSCollectionLayoutSize(
      widthDimension: .fractionalWidth(itemFractionalWidthFraction),
      heightDimension: .fractionalHeight(1)
    )
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    item.contentInsets = NSDirectionalEdgeInsets(top: itemInset, leading: itemInset, bottom: itemInset, trailing: itemInset)
    
    // Group
    let groupSize = NSCollectionLayoutSize(
      widthDimension: .fractionalWidth(1),
      heightDimension: .fractionalHeight(groupFractionalHeightFraction)
    )
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
    
    // Section
    let section = NSCollectionLayoutSection(group: group)
    section.contentInsets = NSDirectionalEdgeInsets(top: itemInset, leading: itemInset, bottom: itemInset, trailing: itemInset)
    
    return UICollectionViewCompositionalLayout(section: section)
  }()
  • itemSize계산할 필요 없이, 화면에 셀을 3개만 띄우고 싶은 경우, group의 count 속성을 사용하면 편리
private func getGridSection() -> NSCollectionLayoutSection {
  let itemSize = NSCollectionLayoutSize(
    widthDimension: .fractionalWidth(0.3),
    heightDimension: .fractionalHeight(1.0)
  )
  let item = NSCollectionLayoutItem(layoutSize: itemSize)
  item.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)
  let groupSize = NSCollectionLayoutSize(
    widthDimension: .fractionalWidth(1.0),
    heightDimension: .fractionalHeight(0.3)
  )
  // collectionView의 width에 3개의 아이템이 위치하도록 하는 것
  let group = NSCollectionLayoutGroup.horizontal(
    layoutSize: groupSize,
    subitem: item,
    count: 3
  )
  let section = NSCollectionLayoutSection(group: group)
  return section
}

Section 별로 다른 레이아웃 사용 방법

섹션 분리: 25~30번셀의 섹션과 31~40번셀의 섹션

  • CompositionalLayout은 하나의 CollectionView에 섹션별로 다른 layout을 구성하기가 쉬운 장점이 존재
  • UICollectionViewCompositionalLayout 인스턴스에서 section별로 분기문을 써서 layout을 다르게하기가 간편
    • 아래처럼 collectionView를 초기화 할 때 getLayout() 메소드를 따로 정의하여 이 메소드만 주입
  private lazy var collectionView: UICollectionView = {
    let view = UICollectionView(frame: .zero, collectionViewLayout: Self.getLayout()) // <-
    view.isScrollEnabled = true
    view.showsHorizontalScrollIndicator = false
    view.showsVerticalScrollIndicator = true
    view.scrollIndicatorInsets = UIEdgeInsets(top: -2, left: 0, bottom: 0, right: 4)
    view.contentInset = .zero
    view.backgroundColor = .clear
    view.clipsToBounds = true
    view.register(MyCell.self, forCellWithReuseIdentifier: "MyCell")
    view.translatesAutoresizingMaskIntoConstraints = false
    return view
  }()
  • getLayout() 정의
    • UICollectionViewCompositionlalLayout의 클로저에서 section 인수를 통해 분기문 작성
static func getLayout() -> UICollectionViewCompositionalLayout {
  UICollectionViewCompositionalLayout { (section, env) -> NSCollectionLayoutSection? in
    switch section {
    case 0:
      let itemFractionalWidthFraction = 1.0 / 3.0 // horizontal 3개의 셀
      let groupFractionalHeightFraction = 1.0 / 4.0 // vertical 4개의 셀
      let itemInset: CGFloat = 2.5
      
      // Item
      let itemSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(itemFractionalWidthFraction),
        heightDimension: .fractionalHeight(1)
      )
      let item = NSCollectionLayoutItem(layoutSize: itemSize)
      item.contentInsets = NSDirectionalEdgeInsets(top: itemInset, leading: itemInset, bottom: itemInset, trailing: itemInset)
      
      // Group
      let groupSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1),
        heightDimension: .fractionalHeight(groupFractionalHeightFraction)
      )
      let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
      
      // Section
      let section = NSCollectionLayoutSection(group: group)
      section.contentInsets = NSDirectionalEdgeInsets(top: itemInset, leading: itemInset, bottom: itemInset, trailing: itemInset)
      return section
    default:
      let itemFractionalWidthFraction = 1.0 / 5.0 // horizontal 5개의 셀
      let groupFractionalHeightFraction = 1.0 / 4.0 // vertical 4개의 셀
      let itemInset: CGFloat = 2.5
      
      // Item
      let itemSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(itemFractionalWidthFraction),
        heightDimension: .fractionalHeight(1)
      )
      let item = NSCollectionLayoutItem(layoutSize: itemSize)
      item.contentInsets = NSDirectionalEdgeInsets(top: itemInset, leading: itemInset, bottom: itemInset, trailing: itemInset)
      
      // Group
      let groupSize = NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1),
        heightDimension: .fractionalHeight(groupFractionalHeightFraction)
      )
      let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
      
      // Section
      let section = NSCollectionLayoutSection(group: group)
      section.contentInsets = NSDirectionalEdgeInsets(top: itemInset, leading: itemInset, bottom: itemInset, trailing: itemInset)
      return section
    }
  }
}
  • 좀 더 간결하게 구현하려면, 아래와 같이 MySection(rawValue: sectionNumber)를 넣어서 사용
    • 구체적인 코드는 이곳 참고
private func getLayout() -> UICollectionViewLayout {
  return UICollectionViewCompositionalLayout { sectionIndex, env -> NSCollectionLayoutSection? in
    switch self.dataSource[sectionIndex] {
    case .main:
      return self.listSection()
    case .sub:
      return self.gridSection()
    }
  }
}

* 전체 코드: https://github.com/JK0369/ExComposionalLayout

 

* 참고

https://demian-develop.tistory.com/22

https://developer.apple.com/documentation/uikit/nscollectionlayoutsection/3199094-orthogonalscrollingbehavior

https://www.raywenderlich.com/5436806-modern-collection-views-with-compositional-layouts

https://lickability.com/blog/getting-started-with-uicollectionviewcompositionallayout/#supplementary-items

Comments