DiffableDataSource cho UITableView trong iOS 13

Introduction

Từ thuở sơ khai của iOS, khi nhắc đến UITableView thì chúng ta phải nhắc đến protocol UITableViewDataSource - protocol có trách nhiệm set data (số section, số item) và configure cell cho table view... Chúng ta phải đảm bảo tính nhất quán giữa data model với data source của table view bằng các method quen thuộc như:

optional func tableView(UITableView, numberOfRowsInSection: Int) -> Int

func numberOfSections(in: UITableView) -> Int

func tableView(UITableView, cellForRowAt: IndexPath) -> UITableViewCell

Mỗi khi data model thay đổi, để update lại UI trên table view, đơn giản nhất chúng ta chỉ cần gọi tableView.reloadData(). Table view sẽ tự động gọi lại các method data source trên và update lại toàn bộ các section và row.

Tuy nhiên khi chỉ cần reload lại một phần của table view, ví dụ thay đổi thứ tự các row, xóa row này, thêm row kia, thay đổi section... Cách giải quyết truyền thống thường thấy ví dụ như sau:

// Bắt đầu update tableView
tableView.beginUpdates()
// Reload section 3 và 4
tableView.reloadSections([3,4], with: .automatic)
// Insert section 1, row 0
tableView.insertRows(at: [IndexPath(row: 0, section: 1)], with: .automatic)
// Delete section 1, row 1
tableView.deleteRows(at: [IndexPath(row: 1, section: 1)], with: .automatic)
// Commit table view's update
tableView.endUpdates()

Với cách làm này đôi khi sẽ rất khó để có thể đảm bảo tính nhất quán của table view data source, đảm bảo các section, các row được update đúng với mỗi action insert, reload, delete.

Chắc hẳn ít nhất một lần bạn đã từng bị crash app với error message sau khi update table view:

*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', 
reason: 'Invalid update: invalid number of sections. 
The number of sections contained in the table view after the update (10) 
must be equal to the number of sections contained in the table view before the update (10), 
plus or minus the number of sections inserted or deleted (0 inserted, 1 deleted).'
***

Ở thời điểm diễn ra sự kiện WWDC 2019, Apple đã giới thiệu một API giúp quản lý data source của UITableViewUICollectionView một cách đơn giản và an toàn hơn. Các developer sẽ không cần phải đau đầu tìm giải pháp đảm bảo tính nhất quán giữa data model và data source khi update table view nữa. API này có tên là Diffable Data Source.

Diffable Data Source API cho phép chúng ta quản lý, update data source của table view và collection view bằng khái niệm snapshot. Snapshot đóng vai trò là một data source tin cậy trung gian giữa view và data model. Khi có sự thay đổi data model, chúng ta đơn giản chỉ cần tạo mới một snapshot và apply nó vào snapshot hiện tại. Hệ thống sẽ tự động nhận diện tất cả sự thay đổi, khác biệt giữa hai snapshot, update lại view với option animation enabled hoặc disabled, rất tiện lợi và dễ dàng. Diffable Data Source không sử dụng IndexPath, thay vào đó sử dụng generic data type để identify các section, row khi dequeue cell...

Tất cả khái niệm về API mới này có thể tham khảo tại video Advances in Data Sources của Apple tại WWDC 2019.

Diffable Data Source Diffing Mechanism

Để đảm bảo hệ thống tự động nhận diện khác biệt trên snapshot thì có một yêu cầu bắt buộc các developer phải tuân theo. Đó là chúng ta phải cung cấp các section và item với các unique value. Vì vậy section và item đều phải conform protocol Hashable khi khởi tạo UITableViewDiffableDataSource.

@available(iOS 13.0, tvOS 13.0, *)
open class UITableViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType> : NSObject, UITableViewDataSource where SectionIdentifierType : Hashable, ItemIdentifierType : Hashable 

Như chúng ta có thể thấy ở trên SectionIdentifierTypeItemIdentifierType đều là các kiểu generic và chúng đều phải conform protocol Hashable. Khi khởi tạo UITableViewDiffableDataSource, mỗi item, mỗi section cũng cần phải có một hashValue unique để tránh confict hash.

Sample Project

Hãy bắt đầu làm quen với Differ Data Source API bằng cách thực hành trên một project đơn giản: hiển thị một table view mà không sử dụng UITableViewDataSource truyền thống.

  1. Hiển thị một list các sinh viên được nhóm theo section quốc tịnh.
  2. Các quốc tịch sẽ là các section và các sinh viên là các row của table view.
  3. Thêm một shuffle navigation button, khi tap vào sẽ xáo trộn data model và update lại table view với animation.

Bạn có thể bắt đầu ngay bằng cách download starter project từ Github repository này.

Starter Project

Giải thích qua một chút về starter project này. Đầu tiên chúng ta sẽ có 1 enum Country thể hiện quốc tịch.

enum Country {
    case vietnam
    case japan
    case china
    case korea

    func toString() -> String {
        switch self {
        case .vietnam:
            return "Vietnam 🇻🇳"
        case .japan:
            return "Japan 🇯🇵"
        case .china:
            return "China 🇨🇳"
        case .korea:
            return "Korea 🇰🇷"
        }
    }
}

Struct Student với 2 property đơn giản và struct Section đại diện cho các section sau khi nhóm list sinh viên theo quốc tịch.

struct Section {
    var country: Country
    var students: [Student]
}

struct Student {
    var name: String
    var country: Country
}

Trong MainViewController.swift, table view được setup theo cách truyền thống:

class MainViewController: UIViewController {

    @IBOutlet private weak var studentsTableView: UITableView!

    // List student
    private var items: [Student] = []
    // List section
    private var sections: [Section] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        setupData()
        setupView()
    }

    private func setupData() {
        // Hard code list student
        items = [
            Student(name: "Nguyen Xuan Thanh", country: .vietnam),
            Student(name: "Ikehara Arisu", country: .japan),
            Student(name: "Park Ji Sung", country: .korea),
            Student(name: "Qing Shan", country: .china),
            Student(name: "Kuno Yuka", country: .japan),
            Student(name: "Shimada Tomiko", country: .japan),
            Student(name: "Ying Yue", country: .china),
            Student(name: "Le Thu Trang", country: .vietnam),
            Student(name: "Nguyen Minh Vuong", country: .vietnam),
        ]

        // Nhóm list student theo property country
        let groupedDict = Dictionary(grouping: items, by: { $0.country })
        // Set list section
        sections = groupedDict.map({Section(country: $0.key, students: $0.value)})
    }

    private func setupView() {
        // Thêm shuffle navigation bar button
        navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Shuffle",
                                                            style: .plain,
                                                            target: self,
                                                            action: #selector(shuffleButtonTapped))

        studentsTableView.register(UITableViewCell.self, forCellReuseIdentifier: "StudentCell")
        studentsTableView.dataSource = self
    }

    @objc private func shuffleButtonTapped() {
        // Xáo trộn thứ tự data model
        sections = sections.map({
            Section(country: $0.country, students: $0.students.shuffled())
        }).shuffled()

        // Reload table view
        studentsTableView.reloadData()
    }

}

UITableViewDataSource quen thuộc như sau:

// MARK: UITableViewDataSource methods

extension MainViewController: UITableViewDataSource {

    func numberOfSections(in tableView: UITableView) -> Int {
        return sections.count
    }

    func tableView(_ tableView: UITableView,
                   numberOfRowsInSection section: Int) -> Int {
        return sections[section].students.count
    }

    func tableView(_ tableView: UITableView,
                   cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "StudentCell", for: indexPath)
        let student = sections[indexPath.section].students[indexPath.row]
        cell.textLabel?.text = student.name

        return cell
    }

    func tableView(_ tableView: UITableView,
                   titleForHeaderInSection section: Int) -> String? {
        return sections[section].country.toString()
    }

}

Như vậy là đã setup xong project với table view đơn giản theo cách cũ. Tiếp theo hãy chuyển sang sử dụng Diffable Data Source để thấy được sự tiện lợi, dễ dàng mà nó mang lại.

Using TableViewDiffableDataSource

Vì chúng ta sẽ không dùng UITableViewDataSource truyền thống nữa, nên trong MainViewController.swift, hãy xóa extension data source và delegate của table view đi.

Để sử dụng Diffable Data Source, chúng ta cần tạo class mới, subclass class UITableViewDiffableDataSource với 2 kiểu generic cho Section IdentifierItem Identifier.

UITableViewDiffableDataSource vẫn có đầy đủ các data source method tương tự với UITableViewDataSource:

    @objc open func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int

    @objc open func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell

    @objc open func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String?

    @objc open func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String?

    @objc open func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool

    @objc open func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath)

    @objc open func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool

    @objc open func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath)
    
    ...

Tuy nhiên có thêm một số method mới, trong đó quan trọng nhất là

  • apply(_:animatingDifferences:completion:): apply snapshot vào table view với option animation.
  • snapshot(): get snapshot hiện tại của table view.

Các bạn có thể tìm hiểu thêm về UITableViewDiffableDataSource tại đây.

Tiếp tục, method set title cho mỗi section sẽ được implement trong class mới sau:

class StudentTableViewDiffableDataSource: UITableViewDiffableDataSource<Country, Student> {

    override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        
        return snapshot().sectionIdentifiers[section].toString()
    }

}

Lúc này chúng ta sẽ phải conform protocol Hashable cho Country và Student. Country là enum nên mặc định đã confirm Hashable rồi.

Conform Hashable với struct Student như sau:

struct Student: Hashable {
    var name: String
    var country: Country

    // override
    func hash(into hasher: inout Hasher) {
        // Combine thêm name và country để hash value unique
        hasher.combine(name)
        hasher.combine(country)
    }
}

Khai báo thêm property diffable data source:

    private var diffableDataSource: UITableViewDiffableDataSource<Country, Student>!

Trong method setupView(), tạo diffable data source và set cho table view hiện tại. Lúc này method tableView(_:cellForRowAt:) trong UITableViewDataSource sẽ tương đương với closure khi contruct diffable data source này.

        diffableDataSource = StudentTableViewDiffableDataSource(tableView: studentsTableView) { tableView, indexPath, student -> UITableViewCell? in
            let cell = tableView.dequeueReusableCell(withIdentifier: "StudentCell", for: indexPath)
            cell.textLabel?.text = student.name
            return cell
        }

Tiếp theo, chỉ cần tạo snapshot mới, set các section, set các item cho mỗi section và apply thôi.

    private func buildAndApplySnapshot() {
        // Tạo mới snapshot
        var snapShot = NSDiffableDataSourceSnapshot<Country, Student>()
        // Set list section
        snapShot.appendSections(sections.map({ $0.country }))
        sections.forEach({
            // Set list item cho mỗi section
            snapShot.appendItems($0.students, toSection: $0.country)
        })
        // Apply snapshot mới vào table view với animation
        diffableDataSource.apply(snapShot, animatingDifferences: true, completion: nil)
    }

Như đã nói ở đầu, khi apply snapshot mới vào, diffable data source sẽ tự động tính toán, nhận biết các thay đổi trong các section, các item dựa trên cơ sở mỗi section, mỗi item đều unique với hash value khác nhau.

Build và run app, table view sẽ được update ngon nghẻ với animation đẹp mắt như sau:

Rất đơn giản và dễ phải không, chúng ta sẽ cần quan tâm đến sự lệch lạc data, lỗi crash khi sai index path nữa.

Conclusion

Trên đây chỉ là một ví dự cực kỳ đơn giản về API Diffable Data Source. Chưa thể thể hiện hết được sức mạnh, tính tiện lợi vượt trội của nó so với data source truyền thống.

Để tìm hiểu thêm, các bạn hãy xem WWDC 2019 session video Advances in UI Data Sources của Apple nhé.

Link final project: https://github.com/oNguyenXuanThanh/StudyReport042020Finish

References: https://www.alfianlosari.com/posts/using-ios13-diffable-datasource-api-in-tableview/


All Rights Reserved