swift

UICollectionView Custom Layout

motosw3600 2022. 2. 3. 16:20

※ 아래 글은 UICollectionView Custom Layout을 참고하여 일부 번역한 글입니다.

UICollectionView Custom Layout

UICollectionView는 iOS6부터 소개되었고, iOS 개발자 사이에 가장 인기있는 UI요소가 되었다. CollectionView는 레이아웃을 처리하기 위해 별도의 개체에 의존하는 data, presentation layer를 분리한 부분이 매력적으로 적용되었다. 레이아웃은 뷰의 배치 및 시각적 속성을 결정하는 역할을 한다.

 

보통 default로 FlowLayout을 사용하는 경우가 많다. 그러나 원하는대로 뷰를 정렬하기 위해 커스텀 레이아웃을 구현할 수도 있다. 이를 통해 View가 유연하고 강력하게 작동 할 수 있다.

 

아래 Pinterest App의 예시를 통해 커스텀 레이아웃을 만들 수 있다.

아래의 과정을 통해 세가지 경우를 배울 수 있다.

  • 커스텀 레이아웃
  • 레이아웃 속성을 계산하고 캐싱하는 방법
  • 동적인 크기의 셀을 다루는 방법

Getting Started

일반 FlowLayout을 적용한 예시 프로젝트는 다음과 같다.

 

갤러리는 표준 flow layout을 사용해 collectionView를 구성했다. 보기엔 괜찮아 보일 수 있지만 layout design을 향상시킬 수 있다.

사진들은 콘텐츠 영역을 완전히 채우지 않는다. 긴 캡션들은 잘리게 된다. 모든 셀의 크기가 동일하기 때문에 사용자 경험은 지루하고 정적으로 보일 수 있다.

각 셀이 필요에 맞는 크기로 자유롭게 배치되는 커스텀 레이아웃으로 디자인을 개선할 수 있다.

Core Layout Process

CollectionView의 레이아웃 프로세스에서 CollectionView와 레이아웃 개체 간의 협업으로 동작된다. 컬렉션 뷰가 레이아웃 정보를 필요로 하면 레이아웃 객체에 특정 순서로 특정 메서드를 호출하여 제공하도록 요청한다.

  • collectionViewContentSize이 메서드는 컬렉션 뷰의 콘텐츠의 너비와 높이를 반환한다. 보이는 콘텐츠 뿐만 아니라 전체 컬렉션 뷰 콘텐츠의 높이와 너비를 반환하도록 구현해야 한다. 컬렉션 뷰는 내부적으로 이 정보들을 사용하여 스크롤 뷰의 컨텐츠 크기를 구성한다.
  • prepare(): 레이아웃 작업이 발생하려고 할 때마다 UIKit은 이 메서드를 호출한다. 컬렉션 뷰의 크기와 항목의 위치를 결정하는데 필요한 계산을 준비하고 수행할 수 있게 한다.
  • layoutAttributesForElements(in:): 이 메서드에서 주어진 사각형 내부의 모든 항목에 대한 레이아웃 속성을 반환한다. UICollectionView LayoutAtAttributes의 배열로 속성을 컬렉션 뷰에 반환한다.
  • layoutAttributesForItem(at:): 이 메서드는 컬렉션 뷰에 레이아웃 정보를 제공한다. 이를 재정의하고 요청된 indexPath에서 항목에 대한 레이아웃 속성을 반환해야 한다.

Calculating Layout Attributes

이 레이아웃에서는 사진의 높이를 미리 알지 못하기 때문에 모든 항목의 높이를 동적으로 계산해야 한다. PinterestLayout에서 이 정보를 제공하는 프로토콜을 선언한다.

 

protocol PinterestLayoutDelegate: AnyObject {
  func collectionView(
    _ collectionView: UICollectionView,
    heightForPhotoAtIndexPath indexPath: IndexPath) -> CGFloat
}

 

이 코드는 사진의 높이를 매개변수로 전달받아 높이를 반환하는 메서드를 요구한다. 위의 메서드 구현은 PhotoStreamViewController에서 프로토콜을 채택하여 구현한다.

 

레이아웃 메서드를 적용하는데 구현해야할 부분이 하나 더 있다. 레이아웃 프로세스를 도와줄 몇몇의 프로퍼티를 추가로 선언해야 한다.

 

// 1
weak var delegate: PinterestLayoutDelegate?

// 2
private let numberOfColumns = 2
private let cellPadding: CGFloat = 6

// 3
private var cache: [UICollectionViewLayoutAttributes] = []

// 4
private var contentHeight: CGFloat = 0

private var contentWidth: CGFloat {
  guard let collectionView = collectionView else {
    return 0
  }
  let insets = collectionView.contentInset
  return collectionView.bounds.width - (insets.left + insets.right)
}

// 5
override var collectionViewContentSize: CGSize {
  return CGSize(width: contentWidth, height: contentHeight)
}

 

위의 코드들은 추후에 레이아웃 정보를 제공하기위해 필요한 프로퍼티들이다.

  1. delegate를 참조한다.
  2. column개수와 cellPadding의 정보를 설정한다.
  3. 계산된 속성들을 캐시하는 배열이다. prepare()를 호출하면 모든 항목의 속성을 계산하여 캐시에 추가한다. 나중에 컬렉션뷰가 레이아웃 속성을 요청할 때 매번 다시 계산하는 대신 캐시를 효율적으로 쿼리할 수 있다.
  4. 콘텐츠 크기를 저장할 두개의 속성을 선언한다. 사진을 추가할 때 contentHeight을 증가시키고 컬렉션 뷰의 너비와 Inset을 사용하여 contentWidth를 계산한다.
  5. collectionViewContentSize는 컬렉션 뷰의 콘텐츠 크기를 반환한다. 이전 단계의 contentWidth및 contentHeight를 사용하여 크기를 계산한다.

컬렉션 뷰의 아이템속성을 계산할 준비가 완료되었다. 이제부터 그것들은 프레임을 구성할 것이다. 아래의 다이어그램을 통해 작업을 수행하는 방법을 확인할 수 있다.

열과 같은 열에서 이전 항목의 위치를 기반으로 모든 항목의 프레임을 계산할 수 있다. 프레임에 대해 xOffxezt을 추적하고 이전 항목의 위치에 대해 yOffset을 추적하여 이를 수행한다.

 

먼저 항목이 속한 열의 시작 x좌표를 사용하여 수평 위치를 계산한 다음 셀 패딩을 추가한다. 수직 위치는 해당 열에있는 이전 항목의 시작 위치에 해당 이전 항목의 높이를 더한 값이다. 전체 항목의 높이는 이미지 높이와 콘텐츠 패딩의 합이다.

 

위의 작업은 prepare()에서 수행한다. 주요 목표는 레이아웃의 모든 항목에 대한 UICollectionViewLayoutAttributes인스턴스를 계산하는 것이다.

 

override func prepare() {
  // 1
  guard 
    cache.isEmpty, 
    let collectionView = collectionView 
    else {
      return
  }
  // 2
  let columnWidth = contentWidth / CGFloat(numberOfColumns)
  var xOffset: [CGFloat] = []
  for column in 0..<numberOfColumns {
    xOffset.append(CGFloat(column) * columnWidth)
  }
  var column = 0
  var yOffset: [CGFloat] = .init(repeating: 0, count: numberOfColumns)
    
  // 3
  for item in 0..<collectionView.numberOfItems(inSection: 0) {
    let indexPath = IndexPath(item: item, section: 0)
      
    // 4
    let photoHeight = delegate?.collectionView(
      collectionView,
      heightForPhotoAtIndexPath: indexPath) ?? 180
    let height = cellPadding * 2 + photoHeight
    let frame = CGRect(x: xOffset[column],
                       y: yOffset[column],
                       width: columnWidth,
                       height: height)
    let insetFrame = frame.insetBy(dx: cellPadding, dy: cellPadding)
      
    // 5
    let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
    attributes.frame = insetFrame
    cache.append(attributes)
      
    // 6
    contentHeight = max(contentHeight, frame.maxY)
    yOffset[column] = yOffset[column] + height
    
    column = column < (numberOfColumns - 1) ? (column + 1) : 0
  }
}

 

  1. 캐시가 비어있고 컬렉션뷰가 존재할 때만 레이아웃 속성을 계산한다.
  2. 열 너비를 기반으로 하는 모든 열의 x좌표로 xOffset배열을 선언하고 채운다. yOffset배열은 모든 열의 y좌표를 추적한다. 각 열에 있는 첫번째 항목의 Offset이므로 yOffset의 각 값을 0으로 초기화 한다.
  3. 이 특정 레이아웃에선 섹션이 하나만 있으므로 첫번째 섹션의 모든 항목을 반복한다.
  4. Frame계산을 수행한다. 너비는 셀 사이의 패딩이 제거된 이전에 계산된 cellWidth이다. delegate에게 사진 높이를 요청한 다음 이 높이와 상단 및 하단에 대해 미리 정의된 cellPadding을 기반으로 프레임 높이를 계산한다. delegate에서 요청한 높이가 없으면 기본 셀 높이(180)을 사용한다. 그런다음 현재열의 x 및 y Offset과 결합하여 속성에서 사용하는 insetFrame을 만든다.
  5. UICollectionVIewLayoutAttributes의 인스턴스를 만들고 insetFrame을 사용하여 프레임을 설정하고 속성을 캐시에 추가한다.
  6. 새로 계산된 항목의 프레임을 설명하도록 contentHeight를 확장한다. 그런 다음 프레임을 기반으로 현재 열에 대한 yOffset을 진행한다. 마지막으로 다음 항목이 다음 열에 배치되도록 열을 정렬한다.
Note: 컬렉션 뷰의 레이아웃이 유효하지 않을 때마다 prepare()가 호출되기 때문에 일반적인 구현에서는 여기에서 속성을 다시 계산해야 하는 경우가 많이 있다. 예를 들어 방향이 변경되면 UICollectionVIew의 경계가 변경될 수 있다. 컬렉션에서 항목이 추가되거나 변경되는 경우에도 변경될 수 있다. 이러한 경우 추가적인 작업을 해줘야 하고 이런 요소를 고려해야 한다.

 

이제 layoutAttributesForElements(in:)을 재정의해야 한다. 컬렉션 뷰는 주어진 사각형에 어떤 항목이 보이는지 결정하기 위해 prepare()이후에 layoutAttributesForElements(in:)을 호출한다.

 

override func layoutAttributesForElements(in rect: CGRect) 
    -> [UICollectionViewLayoutAttributes]? {
  var visibleLayoutAttributes: [UICollectionViewLayoutAttributes] = []
  
  // Loop through the cache and look for items in the rect
  for attributes in cache {
    if attributes.frame.intersects(rect) {
      visibleLayoutAttributes.append(attributes)
    }
  }
  return visibleLayoutAttributes
}

 

여기에서 캐시의 속성을 반복하고 해당 프레임이 컬렉션뷰가 제공하는 rect와 교차하는지 확인한다. 해당 사각형과 교차하는 프레임이 있는 속성을 visibleLayoutAttributes에 추가하면 컬렉션뷰로 반환된다.

 

구현해야하는 마지막 과정은 layoutAttributesForItem(at:)이다.

 

override func layoutAttributesForItem(at indexPath: IndexPath) 
    -> UICollectionViewLayoutAttributes? {
  return cache[indexPath.item]
}

 

캐시로부터 요청한 indexPath에 일치하는 레이아웃 속성을 반환한다.

 

Connecting with UIViewController

레이아웃 액션을 보기전에 delegate를 구현해야 한다. PinterestLayout은 항목 프레임의 높이를 계산할 때 사진 및 캡션의 높이를 제공하기 위해 의존한다. 

 

extension PhotoStreamViewController: PinterestLayoutDelegate {
  func collectionView(
      _ collectionView: UICollectionView,
      heightForPhotoAtIndexPath indexPath:IndexPath) -> CGFloat {
    return photos[indexPath.item].image.size.height
  }
}

 

viewDidLoad에서 PinterestLayout을 설정

 

// Set the PinterestLayout delegate
if let layout = collectionView?.collectionViewLayout as? PinterestLayout {
  layout.delegate = self
}

결과 화면

 

 

 

출처 참고 : https://www.raywenderlich.com/4829472-uicollectionview-custom-layout-tutorial-pinterest

'swift' 카테고리의 다른 글

Memory Debugging(leak Test)  (0) 2022.02.20
DispatchSemaphore  (0) 2022.02.06
Dynamic Cell Size CollectionView  (0) 2022.01.25
Tabbar Paging With CollectionView  (0) 2022.01.25
Carousel Effect  (0) 2022.01.22