본문 바로가기
iOS/트러블 슈팅

[iOS] UICollectionViewFlowLayout으로 Pinterest Layout 구현하기

by MINT09 2024. 1. 31.

처음 과제로 받은 피그마에 Pinterest로 구현된 View를 보고, Compositional로 도전해야지! 라고 호기롭게 생각했다. 그러나 Compositional의 벽은 높았고.. item과 group, section으로 크기를 주는 것에서 item 크기들을 어떻게 다르게 줘야 할지 감이 안 잡히더라. Section별로는 쉬운데!! 다른 라이브러리들을 까보면, Compositional + Combine의 조합으로 pinterest가 구현되어 있었다. 그러나 RxSwift나 Combine을 아직 잘 모르는데, 과제 기간은 매우 짧았기에 차마 이번에 도전할 자신은 없었다. 이미 collectionView, diffable data source, compositional 등 처음 써보는 것이 산더미... 때문에 일단 이것은 숙제로 남겨두고, flow layout으로 구현하기로 했다. (flow layout 관련 공식문서를 읽다가 나와 있는 이 문장에 넘어간 것 같다면 사실입니다.)

 Cells can be the same sizes or different sizes.

그래서 관련 라이브러리들을 왕창 뒤져보았다. 이러한 것들을 보고 얻은 방법은, 각 item마다 사이즈, 위치를 잡아 UICollectionViewLayoutAttributes에 저장해 사용하게 하는 것이다. Header를 사용해야 한다면 Header도 따로 잡아주어야 한다!

코드를 까보자.

private var numberOfColumns = 2
private var cellPadding: CGFloat = 4

위 사진처럼, 세로 2줄짜리 layout을 만들고 싶어서 numberOfColums = 2로 설정해 주었고, 각 Cell 간의 간격은 4로 주었다. 

 

private var headerAttributes = [UICollectionViewLayoutAttributes]()
private var itemAttributes = [UICollectionViewLayoutAttributes]()
private var sectionItemAttributes = [[UICollectionViewLayoutAttributes]]()
private var allItemAttributes = [UICollectionViewLayoutAttributes]()

각 cell들의 UICollectionViewLayoutAttributes들을 저장해줄 배열을 만들었다. 

header의 attribute를 저장할 headerAttributes,

각 아이템들의 attributes를 저장할 itemAttributes,

섹션별로 아이템의 attributes를 구분하기 위해 만들어 놓은 sectionItemAttributes,

헤더, 아이템 모두를 저장할 allItemAttributes.

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

다음으로는 collectionView의 contentSize를 정하기 위한 height와 width이다.

contentHeight는 처음에는 0이지만, 사진이 추가되면 증가할 예정이다.

contentWidth의 경우는 collectionView의 사이즈를 그대로 가져가야 하니 오른쪽 왼쪽의 inset을 뺀 값으로 저장하였다.

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

그 후 collectionViewContentSize 메서드 재정의를 통해서 원하는 값으로 collectionView의 contentSize를 정해준다. 사진이 들어온 만큼 contentSize가 커져야 스크롤이 가능하고, layout 충돌이 일어나지 않는다. 

prepare()

다음은 이 pinterest layout의 하이라이트인 prepare 부분이다. 여기서 우리는 각 item들의 사이즈를 전부 다르게 만들어 위치까지 지정해준 후 그러한 내용들을 UICollectionViewLayoutAttributes 타입으로 저장해 배열에 넣어줄 것이다. 

override func prepare() {
    guard let collectionView else {
        return
    }
        
    var top: CGFloat = 0
        
    let headerHeight: CGFloat = 50
    let headerInset: UIEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: 0)
        
    top += headerInset.top
        
    let attributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, with: NSIndexPath(item: 0, section: 0) as IndexPath)
    attributes.frame = CGRect(x: headerInset.left, y: top, width: collectionView.frame.size.width - (headerInset.left + headerInset.right), height: CGFloat(headerHeight))
        
    headerAttributes.append(attributes)
    allItemAttributes.append(attributes)
        
    top = attributes.frame.maxY + headerInset.bottom
        
    //열 넓이 기반 모든 column에 대해 x좌표와 함께 xOffset 배열 채움.
    let columnWidth = contentWidth / CGFloat(numberOfColumns)
    var xOffset = [CGFloat]() //cell의 x 위치를 나타내는 배열
    for column in 0 ..< numberOfColumns {
        xOffset.append(CGFloat(column) * columnWidth)
    }
        
    //yOffset 배열은 모든 열에 대한 y위치 추적
    //각 열의 첫번째 항목의 offset이기 때문에 배열 값들을 0으로 초기화
    var column = 0 //현재 행의 위치
    var yOffset = [CGFloat](repeating: top, count: numberOfColumns) //cell의 y위치를 나타내는 배열
    //단 하나의 섹션만 있는 레이아웃
    //첫 번째 섹션의 모든 아이템 반복
    for item in 0 ..< collectionView.numberOfItems(inSection: 0) {
        let indexPath = IndexPath(item: item, section: 0)
            
        //프레임 계산
        let photoHeight = CGFloat.random(in: 100...300)
        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)
            
        //인스턴스 생성, frame 사용하여 자체 프레임 설정, 배열에 추가
        let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
        attributes.frame = insetFrame
        itemAttributes.append(attributes)
        allItemAttributes.append(attributes)
            
        //새로 계산된 항목의 프레임을 확장
        contentHeight = max(contentHeight, frame.maxY)
        yOffset[column] = yOffset[column] + height
            
        //다음 항목이 다음 열에 배치되도록
        column = yOffset[0] > yOffset[1] ? 1 : 0
    }
        
    sectionItemAttributes.append(itemAttributes)
}

여기서 만일 contentHeight를 저장할 때 위와 같이 하지 않고 단순히 frame.maxY로 저장하면 다음과 같이 더 짧은 이미지에서 내려가지 않는 문제가 생길 수 있다. 

 

또한 사실 마지막의 sectionItemAttributes에 저장하는 로직은 섹션을 하나만 사용하고 있어서 불필요한 부분이기는 하다. 그러나 작성할 때는 Section을 나누고 싶다는 원대한 꿈이 있었기에 그렇게 작성하였다. 

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
  var visibleLayoutAttributes = [UICollectionViewLayoutAttributes]()
      
  for attributes in allItemAttributes {
    if attributes.frame.intersects(rect) {
      visibleLayoutAttributes.append(attributes)
    }
  }
  return visibleLayoutAttributes
}

다음은 모든 셀과 보충 뷰의 레이아웃 정보를 반환하는 메서드를 재정의하는 로직이다. 처음에는 필요 없을 거라 생각했는데.. 이 메서드가 없이 아이템의 레이아웃 정보와 헤더 레이아웃 정보만 반환하게 했더니, 이런 화면이 나왔다. 

 그래서 찾아보니, 서브 클래스는 이 메서드를 재정의해서 뷰가 지정된 rect와 교차하는 모든 아이템에 대한 레이아웃을 반환하도록 하여 위치를 잡아주어야 한다. 임의로 지정한 attribute 들이기 때문에 현재 화면에 보이는 애들만 위치를 잡을 수 있도록! 

https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617769-layoutattributesforelements

 

layoutAttributesForElements(in:) | Apple Developer Documentation

Retrieves the layout attributes for all of the cells and views in the specified rectangle.

developer.apple.com

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

다음으로 위에서 말한 모든 아이템의 레이아웃 정보를 재정의해 반환해준다. 그런데 아까 section을 나누고 싶어서 sectionItemAttributes를 이렇게 사용하였다고 하였는데 사실상 section을 사용하지 않고 하나로 고정하고 있기에 아래와 같이 작성해도 동일하게 동작한다. 

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

마지막으로 header, 보충뷰에 관한 레이아웃 메서드를 재정의해 반환해준다. 

    
override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
    return headerAttributes[indexPath.section]
}

그러면 완성!

전체 코드

final class PinterestFlowLayout: UICollectionViewFlowLayout {
    //열의 개수
    private var numberOfColumns = 2
    //간격
    private var cellPadding: CGFloat = 4
    
    private var headerAttributes = [UICollectionViewLayoutAttributes]()
    private var itemAttributes = [UICollectionViewLayoutAttributes]()
    private var sectionItemAttributes = [[UICollectionViewLayoutAttributes]]()
    private var allItemAttributes = [UICollectionViewLayoutAttributes]()
    
    //content Size를 저장하기 위한 속성들.
    //contentHeight: 사진이 추가되면 증가
    var contentHeight: CGFloat = 0
    //contentWidth: collectionView의 넓이와 자체 contentInset 기반으로 계산
    var contentWidth: CGFloat {
        guard let collectionView else {
            return 0
        }
        let insets = collectionView.contentInset
        return collectionView.bounds.width - (insets.left + insets.right)
    }
    
    //collectionView의 contentSize를 반환하는 메서드 재정의
    override var collectionViewContentSize: CGSize {
        return CGSize(width: contentWidth, height: contentHeight)
    }
    
    override func prepare() {
        guard let collectionView else {
            return
        }
        
        var top: CGFloat = 0
        
        let headerHeight: CGFloat = 50
        let headerInset: UIEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: 0)
        
        top += headerInset.top
        
        let attributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, with: NSIndexPath(item: 0, section: 0) as IndexPath)
        attributes.frame = CGRect(x: headerInset.left, y: top, width: collectionView.frame.size.width - (headerInset.left + headerInset.right), height: CGFloat(headerHeight))
        
        headerAttributes.append(attributes)
        allItemAttributes.append(attributes)
        
        top = attributes.frame.maxY + headerInset.bottom
        
        //열 넓이 기반 모든 column에 대해 x좌표와 함께 xOffset 배열 채움.
        let columnWidth = contentWidth / CGFloat(numberOfColumns)
        var xOffset = [CGFloat]() //cell의 x 위치를 나타내는 배열
        for column in 0 ..< numberOfColumns {
            xOffset.append(CGFloat(column) * columnWidth)
        }
        
        //yOffset 배열은 모든 열에 대한 y위치 추적
        //각 열의 첫번째 항목의 offset이기 때문에 배열 값들을 0으로 초기화
        var column = 0 //현재 행의 위치
        var yOffset = [CGFloat](repeating: top, count: numberOfColumns) //cell의 y위치를 나타내는 배열
        //단 하나의 섹션만 있는 레이아웃
        //첫 번째 섹션의 모든 아이템 반복
        for item in 0 ..< collectionView.numberOfItems(inSection: 0) {
            let indexPath = IndexPath(item: item, section: 0)
            
            //프레임 계산
            let photoHeight = CGFloat.random(in: 100...300)
            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)
            
            //인스턴스 생성, frame 사용하여 자체 프레임 설정, 캐시에 추가
            let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
            attributes.frame = insetFrame
            itemAttributes.append(attributes)
            allItemAttributes.append(attributes)
            
            //새로 계산된 항목의 프레임을 확장
            contentHeight = max(contentHeight, frame.maxY)
            yOffset[column] = yOffset[column] + height
            
            //다음 항목이 다음 열에 배치되도록
            column = yOffset[0] > yOffset[1] ? 1 : 0
        }
        
        sectionItemAttributes.append(itemAttributes)
    }
    
//    모든 셀과 보충 뷰의 레이아웃 정보 리턴
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
      var visibleLayoutAttributes = [UICollectionViewLayoutAttributes]()
      
      for attributes in allItemAttributes {
        if attributes.frame.intersects(rect) {
          visibleLayoutAttributes.append(attributes)
        }
      }
      return visibleLayoutAttributes
    }
    
    //모든 셀의 레이아웃 정보 리턴
    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return sectionItemAttributes[indexPath.section][indexPath.item]
    }
    
    override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return headerAttributes[indexPath.section]
    }
}