UICollectionView Custom Layout Tutorial

by TracDV
1.7K views

Xây dựng UICollection View Custom Layout và làm thế nào để lưu cache và dynamically size cell.

UICollection view được giới thiệu từ bản iOS 6 và nó đã là UI Control mà các developer hay dùng nhất.

UICollection view có thể hỗ trợ các loại hiển thị khác nhau và đặc biệt là tính năng dynamically size cell hỗ trợ người dùng hiển thị những phần mô tả ngắn mà đầy đủ nội dung cần truyền tải nhất mà không làm thay đổi thiết kế của mình. Như vậy thì UICollection view được sử dụng nhiều ở các app dạng social network, news,…,.

Ở bài viết này tôi sẽ hướng dẫn các bạn

  • Custom layout
  • Xử lý dynamically cell
  • Những điểm cần lưu ý

Tạo custom UICollection View Layout

Bạn có thể tạo một chế độ hiển thị cho UICollection view theo cách riêng bằng cách tuỳ chỉnh layout của bạn.
Collection view layout là lớp con của UICollectionViewLayout, nó xác đinh mọi thuộc tính trong chế độ hiển thị trên UICollectionView của bạn.
Các thuộc tính của UICollectionViewLayout là intances của UICollectionViewLayoutAttributes. Chúng chứa các thuộc tính của từng mục trong chế độ hiển thị của bạn.

Bắt đầu

  • Trước tiên ta cần chuẩn bị một project sample đã được implement UICollection View. UI cần custom là dạng cột và dynamic height.
  • Tạo custom collection view layout
  • Cách dùng Custom Collection View Layout

Tạo Custom Collection View Layout

Tạo file CustomCollectionViewLayout sub class là UICollectionViewLayout

Tiếp theo config cho collection view sử dụng custom layout.
Mở file view chứa collection view

Mở thanh công cụ Attributes inspector. Chọn Custom trên thuộc tính Layout

Như vậy chúng ta đã config thành công cho collection view sử dụng custom layout.

Như vậy để hiển thị chúng cần thực hiện những gì

Như vậy class CustomCollectionViewLayout vừa tạo cần thực hiện những function sau:

  • collectionViewContentSize: function này trả về chiều rộng chiều cao của toàn bộ collection view contents.
  • prepare: UIKit sẽ call function này trước khi có các thay đổi về UI. Đây là nơi mà bạn có thể chuẩn bị các tính toán liên quan đến chế độ xem, kích thước, vị trí ,…,.
  • layoutAttributesForElements(in:): Trong function này bạn trả về các thuộc tính bố cục cho tất cả item dưới dạng array UICollectionViewLayoutAttributes.
  • layoutAttributesForItem(at:): Function này cung cấp thông tin bố trí riêng cho từng item. Bạn cần override lại nó để có thể hiển thị cho từng item mà bạn mong muốn.

Tính toán bố cục

Với layout dạng cột và bạn cần height nó được dãn tự động thì bạn cần tính toán lại bố cục của layout

Mở file CustomCollectionViewLayout và tạo protocol cho nó

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

Trong delegate bạn mới tạo 01 function yêu cầu trả về height, bạn sẽ implement function này trong Controller của chúng.

Tiếp đến bạn cần thực hiện implement class CustomCollectionViewLayout

// 1
    weak var delegate: CustomCollectionViewLayoutDelegate?

    // 2
    private let numberOfColumns = 2
    private let cellPadding: CGFloat = 6

    // 3
    private var cache: [UICollectionViewLayoutAttributes] = []

    // 4
    private var contentHeight: CGFloat = 0

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

Bạn có thể thấy ở đây:

  1. delegate
  2. Số columns bạn cần hiển thị
  3. cache để lưu trữ các thuộc tính hiển thị.
  4. 02 Thuộc tính width và height của contents
  5. Trả về kích thước của collection view’s content. Bạn sử dụng cả 2 thuộc tính contentWidth và contentHeight để tính kích thước

Để tính toán cách hiển thị, mời bạn xem sơ đồ sau:

Bạn sẽ tính toán dựa trên số cột bạn muốn thiển thị và vị trí của mục trước đó trong cùng một cộ. Để tính toán thì bạn dùng xOffset cho cột và yOffset cho vị trí.

Tiếp tục bạn cần override lại function prepare

override func prepare() {
      // 1
      guard
        cache.isEmpty,
        let collectionView = collectionView
        else {
          return
      }
      // 2
      let columnWidth = contentWidth / CGFloat(numberOfColumns)
      var xOffset: [CGFloat] = []
      for column in 0..<numberOfColumns {
        xOffset.append(CGFloat(column) * columnWidth)
      }
      var column = 0
      var yOffset: [CGFloat] = .init(repeating: 0, count: numberOfColumns)
        
      // 3
      for item in 0..<collectionView.numberOfItems(inSection: 0) {
        let indexPath = IndexPath(item: item, section: 0)
          
        // 4
        let photoHeight = delegate?.collectionView(
          collectionView,
          heightForPhotoAtIndexPath: indexPath) ?? 180
        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)
          
        // 5
        let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
        attributes.frame = insetFrame
        cache.append(attributes)
          
        // 6
        contentHeight = max(contentHeight, frame.maxY)
        yOffset[column] = yOffset[column] + height
          
        column = column < (numberOfColumns - 1) ? (column + 1) : 0
      }
    }

Ở function này bạn có thể thấy tính width có thể cho số columns mà bạn muốn hiển thị, và tính height cho từng item và được lưu lại các thuộc tính ở cache.

Tiếp theo bạn override lại function layoutAttributesForElements(in rect: CGRect) function này sẽ được gọi sau khi function prepare() kết thúc

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
      var visibleLayoutAttributes: [UICollectionViewLayoutAttributes] = []
      
      // Loop through the cache and look for items in the rect
      for attributes in cache {
        if attributes.frame.intersects(rect) {
          visibleLayoutAttributes.append(attributes)
        }
      }
      return visibleLayoutAttributes
    }

Ở function này, bạn duyệt lại bộ cache xem frame của từng item có giao nhau với collection view không.

Cuối cùng bạn cần phải override lại function layoutAttributesForItem

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

Ở đây sẽ return về các item theo các indexPath.

Kết nối với ViewController

Bạn mở View Controller chứa nó và thêm

extension CustomCollectionViewController: CustomCollectionViewLayoutDelegate {
  func collectionView(
    _ collectionView: UICollectionView,
    heightForPhotoAtIndexPath indexPath:IndexPath) -> CGFloat {
    return photos[indexPath.item].image.size.height
  }
}

Ở đây tôi đang dùng dynamic height. Height sẽ tự động theo height của image.

Tiếp theo trong function viewDidLoad() bạn thêm

if let layout = collectionView.collectionViewLayout as? CustomCollectionViewLayout {
            layout.delegate = self
        }

Như vậy bạn đã custom thành công layout trên UICollectionView.

Những điều cần lưu ý

Tới đây bạn sẽ gặp bug là bạn reload data cho collection view khi tăng số lượng item hiển thị.
Bạn debug bạn sẽ thấy mọi quá trình bạn làm đã hoàn tất, từ việc get data và add vào list của data source cũng đã đầy đủ, nhưng chỉ thiếu một điều là collection view chỉ chạy lại với số lượng item lần đầu tiên.

Vậy vấn đề ở đây là gì?? tại sao reloadData lại không được và không có bất cứ một dòng log error nào ra ??

Vấn đề ở đây là contents size của collection view không được update, nên collection view không thể hiển thị thêm bất cứ item nào.

Vậy bug ở function nào?

Function nào tính toán lại contents size?

Là func prepare() nó là function được gọi để tính toán size. Ngay ở bước đầu tiên của func prepare()

// 1
      guard
        cache.isEmpty,
        let collectionView = collectionView
        else {
          return
      }

Đoạn code trên nó chỉ tính toán lại contents size lần đầu tiên.

Vậy bước 01 của func prepare() bạn cần làm:

      // 1
      guard
//        cache.isEmpty,
        let collectionView = collectionView
        else {
          return
      }

Như vậy bạn đã custom được layout của UICollectionViewLayout và hoàn toàn có thể update data source của UICollection view mà không bị bug.

Source code tham khảo: https://github.com/TracDV/CustomUICollectionViewLayout

Tham khảo nguồn: https://www.raywenderlich.com/4829472-uicollectionview-custom-layout-tutorial-pinterest

Leave a Comment

* By using this form you agree with the storage and handling of your data by this website.