관리 메뉴

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

[iOS - swift] 3. CollectionView (컬렉션 뷰) - custom layout (grid, pinterest 레이아웃 구현) 본문

iOS 응용 (swift)

[iOS - swift] 3. CollectionView (컬렉션 뷰) - custom layout (grid, pinterest 레이아웃 구현)

jake-kim 2021. 7. 20. 00:42

 

1. CollectionView (컬렉션 뷰) - UICollectionViewFlowLayout

2. CollectionView (컬렉션 뷰) - UICollectionViewFlowLayout을 이용한 CarouselView (수평 스크롤 뷰)

3. CollectionView (컬렉션 뷰) - custom layout (grid, pinterest 레이아웃 구현)

4. CollectionView (컬렉션 ) -실전 사용 방법 (FlowLayout, CustomLayout, binary search, cache)

 


CollectionView, custom layout

CollectionView와 UICollectionViewLayout 개념 포인트

  • prepare: 레이아웃 작업이 발생하려고 할 때 UIKit이 해당 메서드 호출
  • collectionViewContentSize: contents의 너비와 높이를 반환
  • layoutAttributesForElement(in:): 인수로 들어온 rect내부의 모든 항목에 대한 레이아웃 속성(UIColelctionViewLayoutAttributes)을 배열로 반환
  • layoutAttributesForItem(at:): indexPath가 인수로 들어오면 레이아웃 정보를 제공

커스텀 UICollectionViewLayout 정의

  • 높이를 요청하는 delegate protocol 정의
// MyLayout.swift

protocol MyLayoutDelegate: AnyObject {
    func collectionView(_ collectionView: UICollectionView, heightForImageAtIndexPath indexPath: IndexPath) -> CGFloat
}
  • MyLayout 클래스 정의
class MyLayout: UICollectionViewLayout {
    weak var delegate: MyLayoutDelegate?
}
  • 레이아웃을 구현하기 위해 필요한 property 작성
    • cache: 컬렉션 뷰가 레이아웃 속성을 요청할 때 매번 다시 계산하지 않고 해당 cache를 이용하여 효율적으로 제공
    • contentHeight, contentWidth: contentHeight은 cell이 추가될때마다 증가, contentWidth는 증가 > 감소 > 증가 > ...
    • collectionViewContentSize: 크기를 계산하기 위해 이전 contentWidth와 contentHeight를 모두 사용
    fileprivate var numberOfColumns: Int = 2
    fileprivate var cellPadding: CGFloat = 6.0
    fileprivate var cache: [UICollectionViewLayoutAttributes] = []
    fileprivate var contentHeight: CGFloat = 0.0

    fileprivate var contentWidth: CGFloat {
        guard let collectionView = collectionView else {
            return 0.0
        }
        let insets = collectionView.contentInset
        return collectionView.bounds.width - (insets.left + insets.right)
    }
    
    override var collectionViewContentSize: CGSize {
        return CGSize(width: contentWidth, height: contentHeight)
    }

layout 계산

  • 바로 다음에 위치할 cell의 위치를 구하기 위해서 xOffset, yOffset 계산
    • prepare(): 컬렉션에서 항목이 추가되거나 제거 또는 방향이 바뀌는 경우에 호출
    override func prepare() {
        super.prepare()
        guard let collectionView = collectionView else { return }
        cache.removeAll()
        
        // xOffset 계산
        let columnWidth: CGFloat = contentWidth / CGFloat(numberOfColumns)
        var xOffset: [CGFloat] = []
        for column in 0..<numberOfColumns {
            let offset = CGFloat(column) * columnWidth
            xOffset += [offset]
        }

        // yOffset 계산
        var column = 0
        var yOffset = [CGFloat](repeating: 0, count: numberOfColumns)
        for item in 0..<collectionView.numberOfItems(inSection: 0) {
            let indexPath = IndexPath(item: item, section: 0)

            let imageHeight = delegate?.collectionView(collectionView, heightForImageAtIndexPath: indexPath) ?? 0
            let height = cellPadding * 2 + imageHeight
            let frame = CGRect(x: xOffset[column], y: yOffset[column], width: columnWidth, height: height)
            let insetFrame = frame.insetBy(dx: cellPadding, dy: cellPadding)

            // cache 저장
            let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
            attributes.frame = insetFrame
            cache.append(attributes)

            // 새로 계산된 항목의 프레임을 설명하도록 확장
            contentHeight = max(contentHeight, frame.maxY)
            yOffset[column] = yOffset[column] + height

            // 다음 항목이 다음 열에 배치되도록 설정
            column = column < (numberOfColumns - 1) ? (column + 1) : 0
        }
    }
  • rect에 따른 layoutAttributes를 얻는 메서드 재정의
    • layoutAttributesForElement(in:): 인수로 들어온 rect내부 항목에 대한 레이아웃 속성 반환
    • cache에 저장된게 있으면 해당 값 반환
    • 참고) IndexPath로 인수가 들어오면 random access로 O(1)로 효율적으로 탐색할수 있지만, rect로 들어온 경우는 O(n)이 되면 비효율적이므로 O(logn)으로 접근 - binSearch를 이용하여 layoutAttributesForElements(in:) 효율적 사용 방법 참고
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        return cache.filter { rect.intersects( $0.frame) }
    }
  • indexPath에 따른 layoutAttribute를 얻는 메서드 재정의
    • layoutAttributesForItem(at:): 특정 indexPath에 대한 레이아웃 속성 반환
    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return cache[indexPath.item]
    }

사용하는 쪽

  • Model 정의
struct MyModel {
    let color: UIColor
    let commentString: String
    let contentHeightSize: CGFloat

    static func getMock() -> [Self] {

        var datas: [MyModel] = []

        let number = arc4random_uniform(30) // 0 ~ 29
        for i in 0...number {
            let red = CGFloat(arc4random_uniform(256))
            let green = CGFloat(arc4random_uniform(256))
            let blue = CGFloat(arc4random_uniform(256))
            let alpha = CGFloat(drand48()) // 0 ~ 1

            let color = UIColor.init(red: red / 255, green: green / 255, blue: blue / 255, alpha: alpha)
            let tmpHeight = CGFloat(arc4random_uniform(500))
            let imageHeightSize = tmpHeight < 50 ? 50 : tmpHeight
            let myModel: MyModel = .init(color: color,
                                         commentString: "\(i + 1) cell",
                                         contentHeightSize: imageHeightSize)
            datas += [myModel]
        }

        return datas
    }
}
  • CollectionViewCell 정의
class MyCell: UICollectionViewCell {

    static var id: String {
        return NSStringFromClass(Self.self).components(separatedBy: ".").last ?? ""
    }

    var myModel: MyModel? {
        didSet { bind() }
    }

    lazy var containerView: UIView = {
        let view = UIView()

        return view
    }()

    lazy var titleLabel: UILabel = {
        let label = UILabel()

        return label
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)

        setupView()
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError()
    }

    private func setupView() {

        contentView.addSubview(containerView)
        containerView.translatesAutoresizingMaskIntoConstraints = false
        containerView.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true
        containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true
        containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true
        containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true

        titleLabel.text = myModel?.commentString
        containerView.addSubview(titleLabel)
        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        titleLabel.centerXAnchor.constraint(equalTo: containerView.centerXAnchor).isActive = true
        titleLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor).isActive = true
    }

    private func bind() {
        containerView.backgroundColor = myModel?.color
        titleLabel.text = myModel?.commentString
    }
}
  • CollectionView 사용 핵심
    • UICollectionView(frame:collectionViewLayout:) 초기화: 커스텀 layout객체 생성하여 주입
class ViewController: UIViewController {

    var collectionView: UICollectionView!
    var dataSource: [MyModel] = []

    override func viewDidLoad() {
        super.viewDidLoad()

        setupDataSource()

        configure()
    }

    private func configure() {

        let collectionViewLayout = MyLayout()
        collectionViewLayout.delegate = self
        collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
        collectionView.layer.borderWidth = 1
        collectionView.backgroundColor = .systemBackground
        collectionView.contentInset = UIEdgeInsets(top: 23, left: 10, bottom: 10, right: 10)
        view.addSubview(collectionView)

        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
        collectionView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor, constant: 10).isActive = true
        collectionView.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor, constant: -10).isActive = true
        collectionView.heightAnchor.constraint(equalToConstant: 700).isActive = true

        collectionView.delegate = self
        collectionView.dataSource = self
        collectionView.register(MyCell.self, forCellWithReuseIdentifier: MyCell.id)
    }

    private func setupDataSource() {
        dataSource = MyModel.getMock()
    }
}

extension ViewController: MyCollectionViewLayoutDelegate {
    func collectionView(_ collectionView: UICollectionView, heightForImageAtIndexPath indexPath: IndexPath) -> CGFloat {
        return dataSource[indexPath.item].contentHeightSize
    }
}

extension ViewController: UICollectionViewDelegate, UICollectionViewDataSource {

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        print(dataSource.count)
        return dataSource.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MyCell.id, for: indexPath)
        if let cell = cell as? MyCell {
            cell.myModel = dataSource[indexPath.item]
        }
        return cell
    }
}

* source code: https://github.com/JK0369/CollectionViewEx

 

* 참고:

- apple documentation: https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/layouts/customizing_collection_view_layouts

- raywnderlich: https://www.raywenderlich.com/4829472-uicollectionview-custom-layout-tutorial-pinterest

Comments