Skip to content

Data-driven, declarative, reactive, diffable collections (and lists!) for iOS. A modern, fast, and flexible library for UICollectionView done right.

License

Notifications You must be signed in to change notification settings

jessesquires/ReactiveCollectionsKit

Repository files navigation

ReactiveCollectionsKit CI

Data-driven, declarative, reactive, diffable collections (and lists!) for iOS. A modern, fast, and flexible library for UICollectionView done right.

About

This library is the culmination of everything I learned from building and maintaining IGListKit, ReactiveLists, and JSQDataSourcesKit. The 4th time's a charm! 🍀

This library contains a number of improvements, optimizations, and refinements over the aforementioned libraries. I have incorporated what I think are the best ideas and architecture design elements from each of these libraries, while eliminating or improving upon the shortcomings. Importantly, this library uses modern UICollectionView APIs — namely, UICollectionViewDiffableDataSource and UICollectionViewCompositionalLayout, both of which were unavailable when the previous libraries were written. This library has no third-party dependencies and is written in Swift.

What about SwiftUI?

SwiftUI performance is still a significant issue, not to mention all the bugs, missing APIs, and lack of back-porting APIs to older OS versions. SwiftUI still does not provide a proper UICollectionView replacement. Yes, Grid exists but it is nowhere close to a replacement for UICollectionView and UICollectionViewLayout. While SwiftUI's List is pretty good much of the time, both LazyVStack and LazyHStack suffer from severe performance issues when you have large amounts of data.

Main Features

Main Features
🏛️ Declarative, data-driven architecture with reusable components
🔐 Immutable, uni-directional data flow
🔀 Safe from data races with Swift 6 strict concurrency checking
🤖 Automatic diffing for cells, sections, and supplementary views
🎟️ Automatic registration and dequeuing for cells and supplementary views
📐 Automatic self-sizing cells and supplementary views
🔠 Create collections with mixed data types, powered by protocols and generics
🔎 Fine-grained control over diffing behavior for your models
🚀 Sensible defaults via protocol extensions
🛠️ Extendable API, customizable via protocols
📱 Simply UICollectionView and UICollectionViewDiffableDataSource at its core
🙅 Never call apply(_ snapshot:), reloadData(), or performBatchUpdates() again
🙅 Never call register(_:forCellWithReuseIdentifier:) or dequeueReusableCell(withReuseIdentifier:for:) again
🙅 Never implement DataSource and Delegate methods again
🏎️ All Swift and zero third-party dependencies
Fully unit tested

Notably, this library consolidates and centers on UICollectionView. There is no UITableView support because UICollectionView now has a List Layout that obviates the need for UITableView entirely.

Getting Started

Tip

Check out the extensive example project included in this repo.

Here's a brief example of building a simple, static list from an array of data models.

let models = [/* array of some data models */]

// create cell view models from the data models
let cellViewModels = models.map {
    MyCellViewModel($0)
}

// create the sections with cells
let section = SectionViewModel(id: "my_section", cells: cellViewModels)

// create the collection with sections
let collectionViewModel = CollectionViewModel(sections: [section])

// initialize the driver with the view model and other components
let driver = CollectionViewDriver(
    view: collectionView,
    viewModel: collectionViewModel,
    emptyViewProvider: provider,
    cellEventCoordinator: coordinator
)

// the collection view is updated and animated automatically

// when the models change, generate a new view model (like above)
let updated = CollectionViewModel(sections: [/* updated items and sections */])
driver.update(viewModel: updated)

Important

When using this library, you should avoid calling the following UICollectionView APIs:

  • reloadData()
  • reconfigureItems(at:)
  • reloadSections(_:)
  • reloadItems(at:)
  • performBatchUpdates(_:completion:)
  • All UICollectionViewDataSource methods
  • All UICollectionViewDelegate methods

Requirements

  • iOS 15.0+
  • Swift 5.10+
  • Xcode 16.0+
  • SwiftLint

Installation

dependencies: [
    .package(url: "https://github.com/jessesquires/ReactiveCollectionsKit.git", from: "0.1.0")
]

Alternatively, you can add the package directly via Xcode.

Documentation

You can read the documentation here. Generated with jazzy. Hosted by GitHub Pages.

Documentation is also available on the Swift Package Index.

Notes on library architecture

Below are some high-level notes on architecture and core concepts in this library, along with comparisons to the other libraries I have worked on — IGListKit, ReactiveLists, and JSQDataSourcesKit.

Overview

The main shortcomings of IGListKit are the lack of expressivity in Objective-C's type system, some boilerplate set up, mutability, and using sections as the base/fundamental component. While it is general-purpose, much of the design is informed by what we needed specifically at Instagram. What IGListKit got right was diffing — in fact, we pioneered that entire idea. The APIs in UIKit came after we released IGListKit and were heavily influenced by what we did.

The main shortcomings of ReactiveLists are that it uses older UIKit APIs and a custom, third-party diffing library. It maintains entirely separate infrastructure for tables and collections, which duplicates a lot of functionality. There's a TableViewModel and a CollectionViewModel, etc. for use with UITableView and UICollectionView. It is also a bit incomplete as we only implemented what we needed at PlanGrid. It pre-dates the modern collection view APIs for diffing and list layouts. What ReactiveLists got right was a declarative API, using a cell as the base/fundamental component, and uni-directional data flow.

JSQDataSourcesKit in some sense was always kind of experimental and academic. It doesn't do any diffing and also has separate infrastructure for tables and collections, as it pre-dated those modern collection view APIs. It was primarily concerned with constructing type-safe data sources that eliminated the boilerplate associated with UITableViewDataSource and UICollectionViewDataSource. Ultimately, the generics were too unwieldy. See my post, Deprecating JSQDataSourcesKit, for more details. What JSQDataSourcesKit got right was the idea of using generics to provide type-safety, though it was not executed well.

All of this experience and knowledge has culminated in me writing this library, ReactiveCollectionsKit, which aims to keep all the good ideas and designs from the libraries above, while also addressing their shortcomings. I wrote or maintained all of them, so hopefully I'll get it right this time! :)

Immutability and uni-directional data flow

Details that ReactiveLists got right are immutability, a declarative API, and uni-directional data flow. With ReactiveLists, you declaratively define your entire collection view model and regenerate it whenever your underlying data model changes.

Meanwhile, IGListKit is very imperative and mutable. With IGListKit, after you hook-up your IGListAdapter and IGListSectionController objects, you update sections in-place. IGListKit encourages immutable data models but this is not enforceable in Objective-C, nor is it enforced in the API. IGListKit does have uni-directional data flow in some sense, but you provide your data imperatively via IGListAdapterDataSource which also requires you to manually manage a mapping of your data model objects to their corresponding IGListSectionController objects.

ReactiveCollectionsKit improves upon the approach taken by both ReactiveLists and IGListKit, and removes or consolidates the boilerplate required by IGListKit.

The CellViewModel is the fundamental or "atomic" component in the library. It encapsulates all data, configuration, interaction, and registration for a single cell. This is similar to ReactiveLists. In IGListKit, this component corresponds to IGListSectionController. A shortcoming of IGListKit is that the "atomic" component is an entire section of multiple items — a section could have a single item and in this scenario it more closely resembles CellViewModel.

The CollectionViewModel defines the entire structure of the collection. It is an immutable representation of your collection of data models, which can be anything. The "driver" terminology is borrowed from ReactiveLists. This component is more or less equivalent to the IGListAdapter found in IGListKit.

Together, these two core components allow for uni-directional data flow. The general workflow is: (1) fetch or update your data models, (2) from that data, generate your CellViewModel objects and complete CollectionViewModel, (3) set the view model on the CollectionViewDriver, which will then perform a diff using the previously set model and update the UICollectionView.

Diffing: identity and equality

Understanding diffing requires understanding two core concepts: identity and equality. In ReactiveCollectionsKit, these concepts are modeled by DiffableViewModel.

typealias UniqueIdentifier = AnyHashable

protocol DiffableViewModel: Identifiable, Hashable {
    var id: UniqueIdentifier { get }
}

Identity concerns itself with permanently and uniquely identifying a single instance of an object. An identity never changes. Identity answers the question "who is this?" For example, a passport encapsulates the concept of identity for a person. A passport permanently and uniquely identifies and corresponds to a single person. Identity is captured by the Identifiable protocol and the corresponding id property.

Equality concerns itself with ephemeral traits or properties of a single unique object that change over time. Equality answers the question "which of these objects with the same id is the most up-to-date?" For example, a person is a unique entity, but they can change their hairstyle, they can wear different clothes, and can generally change any aspect of their physical appearance. While we can uniquely identify a person using their passport on any day, their physical appearance changes day-to-day or year-to-year. Equality is captured by the Hashable (and Equatable) protocol and the corresponding == and hash(into:) functions.

Using this example, consider constructing a list of people to display in a collection. We can uniquely identify each person (using id) in the collection. This allows us to determine (1) if they are present, (2) their precise position, (3) if they have been deleted/moved/added. Next, we can determine if they have changed (using ==) since we last saw them. This allows us to determine when a unique person in the collection needs to be reloaded or refreshed.

Both IGListKit and ReactiveLists got this correct, but their implementations are more cumbersome and manual. ReactiveCollectionsKit improves upon both of these implementations with the DiffableViewModel protocol above (and Swift's type system). Identifiers can be anything that is hashable, but typically this is only a String. Because Swift can automatically synthesize conformances to Hashable, most clients will get all of that functionality for free. If you need to optimize your Hashable implementation, you can manually implement the protocol:

func hash(into hasher: inout Hasher)

static func == (left: Self, right: Self) -> Bool

Important

The collection view APIs in UIKit do not handle equality. UICollectionViewDiffableDataSource only concerns itself with identity — it handles the structure (inserts/deletes/moves) for you, but you must handle reload (or reconfigure) for item property changes. (See: Tyler Fox.)

This is one of the primary motivations for this library, and the reason why a library like this is necessary. When using UICollectionViewDiffableDataSource, you must track property changes for all items in the collection on your own, and then reload/reconfigure accordingly.

The CellViewModel protocol

As mentioned above, CellViewModel is the "base" or "atomic" component of this library. This is "where the magic happens." A CellViewModel declaratively defines everything needed for a cell to be displayed, diffed, and interacted with. It should encapsulate all data it needs to do configure a cell and handle interaction events. The model also includes a declarative definition of how to register the cell with the collection view for reuse.

The CellViewModel protocol inherits from DiffableViewModel and ViewRegistrationProvider to accomplish these tasks. This allows for automatic and customizable diffing and automatic view registration. Because of Swift's default implementations via protocol extensions, you can get a lot of default behavior for free. As mentioned above, you can get Equatable and Hashable conformances for free via synthesized definitions from the compiler. For ViewRegistrationProvider, you get a default class-based registration for free. This functionality is similar to ReactiveLists. IGListKit also offers automatic registration, but it is very implicit.

Essentially, all data source and delegate methods from the collection view are forwarded to each instance of CellViewModel.

For headers, footers, and supplementary views there is a similar SupplementaryViewModel.

Generics and type-erasure

IGListKit, despite some Swift refinements, suffers from the lack of expressivity in Objective-C's type system. ReactiveLists handles this better, but it pre-dates the modern improvements to Swift's generics and existentials. In ReactiveLists, configuring a cell requires force-casting from UITableViewCell or UICollectionViewCell to the specific cell type for the view model. In ReactiveCollectionsKit, this is solved with generics and associated types.

protocol CellViewModel: DiffableViewModel, ViewRegistrationProvider {
    associatedtype CellType: UICollectionViewCell

    func configure(cell: CellType)

    // other members...
}

Using generics was something that JSQDataSourcesKit got right, sort of. While it was nice to avoid casting view types in JSQDataSourcesKit, the generics proliferated all the way to the data source layer, which resulted in poor API ergonomics and extreme difficulty regarding displaying mixed data types. You also could not mix supplementary view types. You could work around the limitations for cells with an enum, but it was not very practical.

To mitigate those shortcomings experienced in JSQDataSourcesKit and handle the heterogenous types downstream when constructing a section (via SectionViewModel), you must erase the cell types. This functionality is provided via an extension method on CellViewModel.

func eraseToAnyViewModel() -> AnyCellViewModel

SupplementaryViewModel follows a similar design, allowing you to mix types for supplementary views as well.

In practice, this means when using mixed data types, you'll need to eventually convert your specific cell view models to AnyCellViewModel.

let people = [Person]()

let cellViewModels = people.map {
    PersonCellViewModel($0).eraseToAnyViewModel()
}

However, because of Swift, you'll notice that SectionViewModel provides a number of convenience initializers using generics. In the scenarios where you do not have mixed data types, the generic initializers allow you ignore this implementation detail and handle the type-erasure for you.

Additional Resources

Contributing

Interested in making contributions to this project? Please review the guides below.

Also, consider sponsoring this project or buying my apps! ✌️

Credits

Created and maintained by Jesse Squires.

License

Released under the MIT License. See LICENSE for details.

Copyright © 2019-present Jesse Squires.