티스토리 뷰

UITableView 또는 UICollectionView를 사용하면 데이터를 정돈된 형태로 사용자에게 보여줄 수 있습니다. UITableView보다 UICollectionView가 더 다양한 layout으로 보여줄 수 있기 때문에 저는 거의 Collection View를 사용하는 것 같아요. 그래서 UICollectionView를 사용하면서 겪었던 경험들을 이 글에서 공유해보고자 합니다. 트러블슈팅이기도 하고, 개념을 톺아보는 글이기도 하고, 다양한 뷰를 만들어 본 경험을 공유하는 자리이기도 하겠네요.

 

1. cell이 화면에 표현이 되지 않는다?

UICollectionView를 IB가 아닌 코드로 짜는 경우 cell이 예상과 다르게 display 되거나 전혀 보이지 않는 경우 확인해야 할 것이 몇 가지 있습니다. 당연한 것들이지만 막상 '무아지경'으로 코드를 짜다보면 놓칠 수 있는 부분들이라 순서대로 확인해보는게 좋을 것 같네요!

( 1 ) cell register

UICollectioinViewCell을 만들었음에도 불구하고 정작 register하는 코드를 추가하지 않아서 문제가 생깁니다. 하지만 이 경우에는 cell dequeue 자체가 실패하기 때문에 아래와 같은 분명한 에러 메세지를 던져줍니다. 따라서

collectionView.register(Cell.self, forCellWithReuseIdentifier: Cell.identifier코드를 추가해주면 쉽게 해결할 수 있습니다.

dequeue 요청을 한 identifier에 해당하는 등록 된 cell이 없어서 생기는 문제

( 2 ) datasource, delegate 연결

의외로 collectionview의 datasource를 연결을 해놓지 않아서 문제가 생기는 경우도 더러 있습니다. UICollectionViewDelegate의 경우 display와 관련된 일보다는 interaction과 관련된 일들이라 cell이 화면에 잘 보이지 않는 것과는 크게 상관은 없습니다만 비슷한 맥락이라 데려왔습니다. collectionview가 view hierarchy에 올라오기 전에 반드시 연결 되었는지 확인할 필요가 있습니다.

collectionView.dataSource = self

 

( 3 ) constraint의 문제

Constraint가 충돌하게 되면 아래와 같은 메세지를 만날 수 있습니다. 내가 만든 제약사항끼리 충돌을 할 수도 있지만

아래와 같은 메세지를 마주치면서 constraint의 충돌로 일부를 break한다고 하면 2가지 해결 방안이 있습니다.

우선, 제약의 충돌이 내가 만든 제약으로 인해 발생할 수도 있지만 시스템에서 알아서 잡은 auto resizing과 내가 만든 제약이 충돌할 수도 있습니다. 전자의 경우라면 제약사항을 수정하면 되고, 후자의 경우 에러 메세지에 해당하는 UI 요소의 translateAutoresizingMaskIntoConstraints 프로퍼티를 false로 정해주면 됩니다.

 

( 4 ) cell 크기가 지정이 안 되어 있는 경우

위의 두 경우가 아닌데도 화면에 나타나지 않는다면 breakpoint를 찍어볼 때가 되었습니다. 그러면 아마 높은 확률로 cellForRowAt 이 불리지 않는 것을 알 수 있을거에요. cellForRowAt이 불리지 않는 것은 그 이전에서 문제가 생겼다는 것이니 layout 과정에서 문제가 생겼음을 짐작할 수 있습니다.

Layout은 cell을 화면에 어떻게 배치할 것인지를 결정하기 때문에 cell의 크기를 지정해주는 것은 매우 중요한데요, 최소한의 가로 세로를 알려주지 않으면 0으로 정해지기 때문에 화면에 절대 표시되지 않을거에요. cell의 크기를 지정해주는 방법은 3가지가 있습니다.

     - flowlayout의 itemSize 프로퍼티 사용

     - UICollectionViewDelegateFlowLayout 프로토콜의 sizeForItemAt 메소드 사용

     - flowlayout의 estimatedItemSize 프로퍼티 사용

 

예를 들어 Unsplash는 위 처럼 사진에 따라 높이가 다르게 적용되기 때문에 height에 대한 계산을 sizeForItemAt 메소드에서 한 번 해보도록 할게요.

extension ViewController: UICollectionViewDelegateFlowLayout {
    
   func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        
        let photo = viewModel.photos[indexPath.row]
        let width: Double = Double(view.frame.width)
        let height: Double = photo.resizeHeightToFitWidth(width: width)

        return CGSize(width: width, height: height)
    }
    
}

2.  contentInset이 들어간 horizontally scrolling collection view

당근마켓의 '내 근처' 탭에 있는 화면을 캡쳐해왔어요. 아래와 같은 뷰는 다음과 같은 특징이 있어요.

- 가로로 스크롤이 되고 item size가 동일

- collection view의 양 끝에 spacing이 있음

 

아주 간소화해서 아래와 같이 만들어보았습니다.

(한 화면 캡쳐 아니고 스크롤해서 캡쳐한 부분입니다)

우선 각 조건을 맞출 수 있는 코드들은 아래와 같이 구현할 수 있습니다.

( 1 ) 가로로 스크롤 및 item size 동일

// collectionview : flowlayout의 scroll direction을 지정
private lazy var collectionView: UICollectionView = {
   let flowlayout = UICollectionViewFlowLayout()
   flowlayout.scrollDirection = .horizontal
        
   let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowlayout)
   collectionView.register(Cell.self, forCellWithReuseIdentifier: Cell.identifier)
   collectionView.translatesAutoresizingMaskIntoConstraints = false
   return collectionView
}()

// flowlayout 프로토콜에서 item size 또는 flowlayout의 프로퍼티에서 지정
extension ViewController: UICollectionViewDelegateFlowLayout {
   func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        
      let width: CGFloat = view.frame.width * 0.9
      let height: CGFloat = 40
        
      return CGSize(width: width, height: height)
   } 
}

( 2 ) collection view의 양 끝에 spacing

이 spacing은 collectionview의 constraint로 잡는 것이 아닙니다. Collection view에 constraint를 잡으면  scroll을 해도 양 끝이 채워지지 않겠죠. 아래처럼요. 하지만 만들고 싶은건 스크롤이 될 때는 양 끝은 채워져야 하거든요? 그러기 위해서는 contentInset 값으로 조절을 해주면 됩니다.

collectionView.contentInset = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20)

3. DataSource가 계속 추가되는 상황에서의 custom layout (2 column)

제가 처음 custom layout을 만들 때 Apple 공식 개발자 문서raywenderlich에서 제공하는 튜토리얼을 참고했었습니다. UICollectionView는 자체적인 layout process를 가지고 있는데요, 그 process에 맞춰 불리는 필수 메소드를 구현하면 custom layout을 만들 수 있습니다. 필수적으로 제공해야하는 정보는 다음과 같습니다.

  • (메소드) prepare
    : Collection view의 크기와 item의 위치를 계산하는 역할로, invalid layout이 불리거나 reloadData가 불렸을 때 그리고
      scroll이 되는 동안에 자동적으로 invalidate  되면 UIKit이 호출합니다.
  • (프로퍼티) collectionViewContentSize
  • (메소드) layoutAttributesForElements
  • (메소드) layoutAttributesForItem

제가 custom layout을 만들 때 꽤나 애를 먹였던 문제가 있는데요, 실제로 collection view를 채우는 데이터는 처음부터 모두 가지고 있는 것이 아니라 네트워크를 통해 계속해서 받아오잖아요? 그 때 layout을 어떻게 업데이트 해야할까요? 우선 제가 작성한 prepare 메소드의 코드는 아래와 같습니다.

override func prepare() {
   guard let collectionView = collectionView,
         cache.isEmpty else { return }
        
   var currentColumn = 0
   let columnWidth = contentWidth / 2
   let xOffset: [CGFloat] = [0, columnWidth]
   var yOffset: [CGFloat] = [0, 0]
        
   (0..<numberOfItems).forEach { item in
      let indexPath = IndexPath(item: item, section: 0)
            
      // Calculate Frame
      let height = delegate?.collectionView(collectionView, heightForCellAt: indexPath) ?? 100
      let frame = CGRect(x: xOffset[currentColumn], y: yOffset[currentColumn],
                         width: columnWidth, height: height)
            
      // UICollectionViewLayoutAttributes
      let cellAttributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
      cellAttributes.frame = frame
      cache.append(cellAttributes)
            
      // 전체 content값 recalculate
      contentHeight = max(contentHeight, frame.maxY)
            
      // yOffset 값 반영
      yOffset[currentColumn] = yOffset[currentColumn] + height
            
      // 다음 column
      currentColumn = (currentColumn == 0) ? 1 : 0
   }
}

#시도 1. cache가 비어있는지 검사를 하지 않는다

처음 시도한 방법은 guard문으로 검사하던 cache.isEmpty 부분을 없애는 것이었습니다. 뿌려줘야 할 데이터를 모두 갖고 있는 상태라면, layoutAttribute들이 이미 저장되어 있기 때문에 다시 prepare 메소드를 통해 계산을 할 필요가 없기 때문에 쓴 코드인데요, 갖고 있는 데이터가 추가되고 바뀐다면 layoutAttribute들을 다시 계산해야하거든요. Collection view의 content size도 당연히 달라질테니 계산을 해줘야 하구요.

하지만 이렇게 했을 때 문제가 생깁니다. cellAttribute를 계속 append를 하면 배열의 크기가 계속해서 커지는 문제도 있고 이미 자리하고 있던 cell들에 대해서도 다시 계산을 해서 배치를 하기 때문에 화면이 깜빡(?)하는 현상이 나타납니다. 사용자가 스크롤하는데 화면이 계속 깜빡거리면 사용자 경험에 나쁘죠.

 

#시도 2. 필요할 때 cache를 비운다

그래서 선택한 방법이 필요할 때 cache를 필요할 때 비워서 cache 용도로 쓰는 배열을 초기화 시켜주는 것이었어요. 데이터가 추가 될 때, reload를 하게 될 때 cache를 비움으로 인해 다시 계산을 할 수 있게 하는거죠!

 

그러나 cache를 비우더라도 layout이 업데이트 될 일이 생겨 prepare 메소드가 불리면 모든 item들의 layout attribute며 content size를 다시 계산하는 것은 여전합니다. 그래서 새로운 데이터를 불러오고 나서도 조금 버벅이는 시간이 있더라구요. 이 부분을 개선해보겠습니다.

 

#시도 3. 마지막 계산한 item 이후로 이어간다

결국 layout을 갈아치울 일이 아니라면 마지막 계산한 item의 state를 알고 있다가 추가 된 item들에 대해서만 처리를 하면 해결되는 부분이잖아요? 그렇다면 중요한 상태를 저장을 하고 있게 하면 되지 않을까요? 아래 사항들을 기존 prepare 메소드에서 변경해보았습니다.

  • last index를 tracking 할 수 있는 변수 생성
  • content size (세로로만 변경되는 layout을 만들고 있으니 y offset)에 영향을 미치는 변수는 prepare 메소드 밖에 두기
  • number of items > cache.count 일 때만 prepare 메소드 실행

데이터를 더 받아와서 number of items가 계산 되어서 저장 된 layout attributes보다 많을 때만 더 추가하면 되므로 아래와 같이 코드를 바꿀 수 있었습니다.

import UIKit

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

final class SearchLayout: UICollectionViewLayout {
    
    private var cache = [UICollectionViewLayoutAttributes]()
    private var lastIndex: IndexPath = IndexPath(item: 0, section: 0)
    private var yOffset: [CGFloat] = [0, 0]
    private let numberOfColumns = 2
    private var contentHeight: CGFloat = 0
    private var contentWidth: CGFloat {
        
        guard let collectionView = collectionView else { return 0 }
        return collectionView.bounds.width
    }
    private var numberOfItems: Int {
        
        guard let collectionView = collectionView else { return 0 }
        return collectionView.numberOfItems(inSection: 0)
    }
    override var collectionViewContentSize: CGSize {
        
        return CGSize(width: contentWidth, height: contentHeight)
    }
    
    weak var delegate: SearchLayoutDelegate?

    // MARK: - Prepare()
    override func prepare() {
        guard let collectionView = collectionView,
              cache.isEmpty || numberOfItems > cache.count else { return }
        
        var currentColumn = 0
        let columnWidth = contentWidth / 2
        let xOffset: [CGFloat] = [0, columnWidth]
        
        (lastIndex.item..<numberOfItems).forEach { item in
            let indexPath = IndexPath(item: item, section: 0)
            lastIndex = indexPath
            
            // Calculate Frame
            let height = delegate?.collectionView(collectionView, heightForCellAt: indexPath) ?? 100
            let frame = CGRect(x: xOffset[currentColumn], y: yOffset[currentColumn],
                               width: columnWidth, height: height)
            
            // UICollectionViewLayoutAttributes
            let cellAttributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
            cellAttributes.frame = frame
            cache.append(cellAttributes)
            
            // 전체 content값 recalculate
            contentHeight = max(contentHeight, frame.maxY)
            
            // yOffset 값 반영
            yOffset[currentColumn] = yOffset[currentColumn] + height
            
            // 다음 column
            currentColumn = (currentColumn == 0) ? 1 : 0
        }
    }
    
    // MARK: - Attributes for Elements
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        
        return cache.filter { $0.frame.intersects(rect) }
    }
    
    // MARK: - Layout Attributes for Item
    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        
        return cache[indexPath.item]
    }

}

extension SearchLayout {
    
    func emptyCache() {
        cache.removeAll()
    }
    
    func resetLayout() {
        contentHeight = 0
        yOffset = [0, 0]
        cache.removeAll()
        lastIndex = IndexPath(item: 0, section: 0)
    }
    
}
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함