UICollectionView cell dynamic height 구현하기(1)_CustomLayout

Zeto·2023년 1월 17일
2

Swift_UIKit

목록 보기
9/12

이번에 작업을 하다보니 메신저 화면을 구현해야 하는 상황에 맞닥뜨리게 되었다. 다행인지 아쉬운 건지는 몰라도 일단 메신저 전체가 아닌, 대화방만 구현하면 되는 터라 부담이 좀 덜하긴 했지만 그럼에도 처음 작업해보는 지라 약간의 두려움을 안고 작업을 시작하게 되었다.
아니나 다를까 메신저를 주고받을 때, UI적으로 가장 핵심인 가변적 높이 조정이 되지 않으면서 초장부터 막히기 시작했다. 이를 해결해보기 위해 레이아웃을 커스텀해보는 등 노력해보면서 여러 자료들을 찾아보고 겨우겨우 적용할 수 있었다. 이러한 삽질로 얻어낸 dynamic height를 적용한 방법을 까먹지 않도록 정리해보고자 한다.

🤣 첫번째 삽질

🔅 CustomLayout과 Delegate 활용

가장 먼저 시도해본 방법이 바로 직접 UICollectionViewLayout을 커스텀하고 딜리게이트와 더미 셀을 통해서 dynamic height를 적용해보는 것이었다. 솔직히 전체적인 맥락은 이해되지만 위대한 인터넷의 도움 없이는 다시 적용해볼 엄두도 안 나는 방법이었다.

먼저 CustomLayout을 구현하기 위해서는 위와 같은 CollectionView LifeCycle을 보고 Item이 그려지는 방식을 이해하는 것이 중요했다. 일단 prepare라는 함수가 가장 먼저 불려오면서 x와 y의 위치를 지정하고 각각의 너비, 높이 값을 기반으로 화면에 그려주기에 해당 오버라이드 함수에 원하는 형태의 레이아웃에 맞춰서 로직을 구현해주어야 한다.

override func prepare() {
	super.prepare()
        
    guard let collectionView = collectionView else { return }
        
    // 중복 방지를 위해 cache 값을 비워줌
    self.cachedAttributes.removeAll()
        
    // xOffset 계산
    // 컬럼이 여러개라면 yOffset처럼 계산 로직이 필요
    let columWidth: CGFloat = contentWidth
    let xOffset: CGFloat = 0
        
    // yOffset 계산
    var yOffset: CGFloat = 0
        
    for item in 0..<collectionView.numberOfItems(inSection: 0) {
    	let indexPath = IndexPath(item: item, section: 0)
            
        // 동적 높이 계산
        let cellHeight = delegate?.collectionView(collectionView, heightForCellAtIndexPath: indexPath) ?? 0
        let height = cellPadding * 2 + cellHeight
            
        // item의 frame
        // insetBy만큼 터치 인식 영역이 증가하거나 감소
        let frame = CGRect(x: xOffset, y: yOffset, width: columWidth, height: height)
        // dx, dy가 양수이면 bounds의 크기 감소, 음수이면 bounds의 크기 증가
        let insetFrame = frame.insetBy(dx: 0, dy: cellPadding)
            
        // cache 저장
        let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
        attributes.frame = insetFrame
        cachedAttributes.append(attributes)
            
        // 새로 계산된 항목의 프레임을 설명하도록 확장
        contentHeight = max(contentHeight, frame.maxY)
        yOffset = yOffset + height
	}
}

여기서 delegate?.collectionView(,)를 호출해서 받아온 값이 동적 높이 값이다. 이와 함께 cachedAttributes라는 변수는 UICollectionViewLayoutAttributes를 담는 배열 변수로서 해당 객체는 각 아이템들의 bound부터 frame, size, isHidden 등의 속성 값을 담고 있는 객체이다.

앞서 말했듯이 이 prepare 함수가 호출이 되면 cachedAttributes에 각각의 아이템 정보들을 담아주는데, 이후에 각 attributes를 반환해주는 함수가 호출되었을 때 저장되어있던 이 값들이 반환되는 것이다.

// 모든 아이템들에 대한 레이아웃 attributes 리턴 (보여지는 부분 / rect와 겹치는 부분)
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
	// rect와 겹치는 부분 리턴
    return cachedAttributes.filter { rect.intersects( $0.frame) }
}
    
// 아이템에 대한 layout 속성을 리턴
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
	return cachedAttributes[indexPath.item]
}

⚠️ Delegate 구현, 그리고 Dummy Cell

이번에는 커스텀 레이아웃의 prepare 함수가 호출되었을 때, 더미셀을 이용해서 동적 높이를 전달해 줄 딜리게이트가 구현되어야 한다.

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

......

extension MessageViewController: MessageCollectionViewLayoutDelegate {

    func collectionView(_ collectionView: UICollectionView, heightForCellAtIndexPath indexPath: IndexPath) -> CGFloat {
        let width = collectionView.bounds.width
        let estimateHeight: CGFloat = 200.0
        let dummyCell: MessageCell = .init(frame: .init(x: 0, y: 0, width: width, height: estimateHeight))
        dummyCell.setCell(with: messages[indexPath.row].message, type: messages[indexPath.row].chatType)
        dummyCell.layoutIfNeeded()

        let estimateSize = dummyCell.systemLayoutSizeFitting(.init(width: width, height: estimateHeight))

        return estimateSize.height
    }
}

현재의 CollectionViewIndexPath 값을 넘겨받아 해당 아이템의 높이 값을 리턴해주는데, 이를 위해서는 정확한 높이 값을 구해줄 수 있도록 적절한 더미셀을 구현해줄 필요가 있다. 이렇게 작성하면 각 메세지에 맞춰서 원하는 대로 레이아웃이 나올 수 있다.

하지만 이러한 로직을 찬찬히 뜯어보면서 아웃풋을 내기 위해서 들여야하는 인풋이 너무 크다는 느낌을 지울 수 없었다. 일단 커스텀 레이아웃을 구현하기에는 레이아웃 자체만 봤을 때, 그렇게까지 복잡한 형태가 아니었다. 데이터가 들어가면 이에 맞춰서 셀의 높이도 조정될 수 있으면 될 뿐이고 셀의 너비는 CollectionView의 너비와 동일하면 되는 형태이기 때문이다.

이와 함께 더미셀의 존재가 큰 부담이었다. 각각의 셀마다 더미셀을 먼저 생성해서 높이 값을 구해주는데, 이를 위해 더미셀 생성 때마다 layoutIfNeeded를 호출해줘야 하는 점과 앞으로 받아올 데이터 값은 서버를 통해 비동기로 들어올 것이라는 점들이 해당 로직이 더더욱 비효율적이지 않나라는 의구심을 들게 만들었다.

결국 해당 로직 외에 조금이라도 더 효율적인 로직을 구현해볼 수 있을까라는 생각이 들었고 몇가지 삽질을 더 진행하였는데 해당 내용은 다음 포스팅에서 작성하고자 한다. 추가적으로 전체 로직은 본인 깃허브에 업로드 되어 있으며, 각각의 방법에 따라 브랜치가 분리되어 있다. (아직 이것저것 만져보고 있는 상황이라 별 내용이 없습니다ㅠ)

👺 1차 정리


레이아웃은 위의 흐름대로 로직이 불리면서 그려지므로 이에 맞춰서 본인이 원하는 형태의 레이아웃대로 그려지도록 커스텀해주면 된다. 다만 FlowLayout이나 CompositionalLayout도 존재하기 때문에 일반적으로 구현하기 까다로운 레이아웃이 아니라면 기존의 레이아웃을 활용하는 것이 좋다.

profile
중2병도 iOS가 하고싶어

0개의 댓글