+4

Hướng dẫn custom hiển thị card với UICollectionViewLayout

1. Giới thiệu về UICollectionViewLayout

UICollectionViewLayout là lớp trừu tượng cơ bản. Bạn có thể kế thừa từ nó để tạo ra và bố cục layout cho UICollectionView. Công việc bố cục tạo ra chủ yếu cho vị trí của cell, supplementary views và decoration views trong UICollectionView. Khi đó UICollectionView sẽ sử dụng những thông tin trong đó để thể hiện nội dụng lên màn hình cho chung ta thấy.

Những func cần thiết để override

  • collectionViewContentSize : Dùng để trả về content size cho UICollectionView sau khi đã bố cục layout.
  • layoutAttributesForElements(in:) : Trả về các element sẽ hiện thị trong khung hình.
  • layoutAttributesForItem(at:) : Trả về các item (cell) ứng với indexPath.
  • layoutAttributesForSupplementaryView(ofKind:at:): Trả về các item SupplementaryView (Header hoặc Footer) ứng với indexPath.
  • prepare(): Sử dụng func này để tính toán và thiết lập các item cho collection view. Apple khuyến cáo nếu muốn cache lại thì nên sử dụng func này
  • shouldInvalidateLayout(forBoundsChange:): Check sự thay đổi layout cho collection view.
  • invalidateLayout(): Huỷ bỏ bố cục hiện tại và thiết lập lại cho bố cục mới.

2. Bắt đầu custom cho UICollectionView

1. Định nghĩa delegate cho collection view layout

Bước đầu, cần định nghĩa delegate cho collection view layout để có thể nhận được các thông tin cho việc custom collection view layout như size items , size header, section insets

protocol  ATMCollectionViewLayoutDelegate: class {
    func collectionViewLayout(collecitionViewLayout: CardCollectionViewLayout, sizeForItem indexPath: IndexPath) -> CGSize

    func collectionViewLayout(colletionViewLayout: CardCollectionViewLayout, insetForSection section: Int) -> UIEdgeInsets

    func collectionViewLayout(collectionViewLayout: CardCollectionViewLayout, sizeForHeaderSection section: Int) -> CGSize
}

Sử dụng extension để thiết lập các giá trị ban đầu cho delegate. Cách này có thể giúp khi controller conform từ delegate không cần phải implement hết toàn bộ function trong delegate. Có thể sử dụng cách này thay có optional func trong protocol của Objective C

extension ATMCollectionViewLayoutDelegate {
    func collectionViewLayout(collecitionViewLayout: ATMCollectionViewLayout, sizeForItem indexPath: IndexPath) -> CGSize {
        return CGSize.zero
    }

    func collectionViewLayout(colletionViewLayout: ATMCollectionViewLayout, insetForSection section: Int) -> UIEdgeInsets {
        return UIEdgeInsets.zero
    }

    func collectionViewLayout(collectionViewLayout: ATMCollectionViewLayout, sizeForHeaderSection section: Int) -> CGSize {
        return CGSize.zero
    }

}

2.Tạo subclass cho UICollectionViewLayout

Đầu tiên, cần tạo 1 subclass từ UICollectionViewLayout có tên là CardCollectionViewLayout. Trong này, sẽ dùng để override các function phía trên để custom lại layout cho collection giống như trong demo.

weak var delegate: ATMCollectionViewLayoutDelegate?

    //Khai báo chiều cao của thẻ
    var itemHeight: CGFloat = 40

    //Xác định hướng scroll cho collection view
    var scrollDirection: UICollectionViewScrollDirection = .vertical


    var contentWidth: CGFloat = 0
    var contentHeight: CGFloat = 0

    // Định nghĩa 1 mảng itemAttributes dùng để lưu lại các item là cell
    private var itemAttributesCache: Array<UICollectionViewLayoutAttributes> = []
    // Định nghĩa 1 mảng headerAttributes dùng để lưu lại các item là header
    private var headerAttributesCache: Array<UICollectionViewLayoutAttributes> = []

Chuẩn bị tính toán và bố cục layout cho collection view. Ta override lại function prepare() và tính toán layout ở trong đó

override func prepare() {
        super.prepare()

        // Kiểm tra đã khởi tạo layout chưa, nếu có rồi thì không tính toán lại layout
        guard itemAttributesCache.isEmpty, headerAttributesCache.isEmpty, let collectionView = collectionView else {
            return
        }

        // Tính toán phần dimension theo vertical hoặc horizontal
        let fixedDimension: CGFloat
        if scrollDirection == .vertical {
            fixedDimension = collectionView.frame.width - (collectionView.contentInset.left + collectionView.contentInset.right)
            contentWidth = fixedDimension
        } else {
            fixedDimension = collectionView.frame.height - (collectionView.contentInset.top + collectionView.contentInset.bottom)
            contentHeight = fixedDimension
        }

        //Sử dụng để lưu lại khoảng cách của các item và header
        var additionalSectionSpacing: CGFloat = 0


        for section in 0..<collectionView.numberOfSections {

            //Lấy size của header view nếu có
            let sizeHeaderSection = (delegate ?? self).collectionViewLayout(collectionViewLayout: self, sizeForHeaderSection: section)

            //Số lượng item của section
            let itemCount = collectionView.numberOfItems(inSection: section)

            //Lấy content inset của section nếu có
            let sectionInset = (delegate ?? self).collectionViewLayout(colletionViewLayout: self, insetForSection: section)

            //Kiểm tra điều kiện để tính toán layout cho header section
            if sizeHeaderSection.width > 0 && sizeHeaderSection.height > 0 && itemCount > 0 {
                let frame: CGRect
                //Tính toán frame cho header section
                if scrollDirection == .vertical {
                    frame = CGRect(x: 0, y: additionalSectionSpacing, width: sizeHeaderSection.width, height: sizeHeaderSection.height)
                } else {
                    frame = CGRect(x: additionalSectionSpacing, y: 0, width: sizeHeaderSection.height, height: sizeHeaderSection.width)
                }
                //Khởi tạo layout attrubute của header và set frame
                let headerLayoutAttribute = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: UICollectionElementKindSectionHeader, with: IndexPath(item: 0, section: section))
                headerLayoutAttribute.frame = frame
                headerLayoutAttribute.zIndex = section * 1000
                // Đưa header vào mảng để sử dụng cho việc cache dữ liệu
                headerAttributesCache.append(headerLayoutAttribute)

                // Tính lại khoảng cách với
                additionalSectionSpacing += frame.height
            }

            //Tính lại khoảng cách với content inset của section
            if sizeHeaderSection.width > 0 && sizeHeaderSection.height > 0 {
                additionalSectionSpacing += scrollDirection == .vertical ? sectionInset.top : sectionInset.left
            }

            for item in 0..<itemCount {
                let indexPath = IndexPath(item: item, section: section)
                //Lấy item size của collection
                let itemSize = (delegate ?? self).collectionViewLayout(collecitionViewLayout: self, sizeForItem: indexPath)

                let frame: CGRect
                //Tính toán frame của item
                if scrollDirection == .vertical {
                    let widthItem = itemSize.width - (sectionInset.left + sectionInset.top)
                    frame = CGRect(x: sectionInset.left, y: additionalSectionSpacing, width: widthItem, height: itemSize.height)
                } else {
                    let heightItem = itemSize.height - (sectionInset.top + sectionInset.bottom)
                    frame = CGRect(x: additionalSectionSpacing, y:sectionInset.top , width: itemSize.width, height: heightItem)
                }

                //Khởi tạo layout attrubute của cell và set frame cho nó
                let itemLayoutAttribute = UICollectionViewLayoutAttributes(forCellWith: indexPath)
                itemLayoutAttribute.frame = frame
                //Set zIndex để xác định thứ tự layout
                itemLayoutAttribute.zIndex = section * 1000 + item

                //Add layout vào mảng item cho việc cache
                itemAttributesCache.append(itemLayoutAttribute)

                //Tính toán lại khoảng cách
                if item == itemCount - 1 {
                    additionalSectionSpacing +=  scrollDirection == .vertical ? frame.height + sectionInset.bottom : frame.width + sectionInset.right
                } else {
                    additionalSectionSpacing += itemHeight
                }

                if scrollDirection == .vertical {
                    contentHeight = additionalSectionSpacing
                } else {
                    contentWidth = additionalSectionSpacing
                }
            }
        }
    }

Xong phần chuẩn bị layout thì chuyển tiếp qua công việc kiểm tra và đưa layout ra ngoài. Lúc này ta sẽ cần override 3 function chính là :

  • layoutAttributesForElements(in:) : Trả về các element sẽ hiện thị trong khung hình.
  • layoutAttributesForItem(at:) : Trả về các item (cell) ứng với indexPath.
  • layoutAttributesForSupplementaryView(ofKind:at:): Trả về các item SupplementaryView (Header hoặc Footer) ứng với indexPath. Đây là 3 function sẽ nhận về các layout và hiện nó ra ngoài collection view
    //Trả về các layout atrribute sẽ được hiện thị ở trong khung
  //Trả về các layout atrribute sẽ được hiện thị ở trong khung
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        let headerInRect = headerAttributesCache.filter { (header) -> Bool in
            //Kiểm tra frame của header có giao với khung cần hiển thị
            header.frame.intersects(rect)
        }

        let itemInRect = itemAttributesCache.filter { (item) -> Bool in
            //Kiểm tra frame của item có giao với khung cần hiển thị
            return item.frame.intersects(rect)
        }
        
        return headerInRect + itemInRect
    }

    //Trả về item layout atrribute với vị trí là indexPath
    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return itemAttributesCache.first {
            return $0.indexPath == indexPath
        }
    }

    //Trả về header hoặc footer layout atrribute với vị trí là indexPath
    override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        if elementKind == UICollectionElementKindSectionHeader {
            return headerAttributesCache.first{ $0.indexPath == indexPath }
        }
        return nil
    }

Kiểm tra khung hiển thị có bị thay đổi và yêu cầu update lại thông tin. Nó sẽ được gọi khi có sự thay đổi về frame của collection view hoặc do thây đổi hướng của thiết bị

    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        if scrollDirection == .vertical, let oldWidth = collectionView?.bounds.width {
            return oldWidth != newBounds.width
        }
        if scrollDirection == .horizontal, let oldHeight = collectionView?.bounds.height {
            return oldHeight != newBounds.height
        }
        return false
    }

Cuối cùng là thiết lập lại dữ liệu nếu có sự thay đổi về bố cục hiển thị ở function phía trên.Lúc này funtion invalidateLayout() sẽ được gọi

    override func invalidateLayout() {
        super.invalidateLayout()
        itemAttributesCache = []
        headerAttributesCache = []
        contentWidth = 0
        contentHeight = 0
    }

3.Kết thúc

Mình đã hướng dẫn cơ bản cho các bạn việc custom collection view sử dụng UICollectionViewLayout.Các bạn có thể tại class custom về tại đây. Cảm ơn các bạn đã đọc bài viết của mình !


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.