Data-driven, declarative, reactive, diffable collections (and lists!) for iOS. A modern, fast, and flexible library for UICollectionView
done right.
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.
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 | |
---|---|
🏛️ | 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.
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
- iOS 15.0+
- Swift 5.10+
- Xcode 16.0+
- SwiftLint
dependencies: [
.package(url: "https://github.com/jessesquires/ReactiveCollectionsKit.git", from: "0.1.0")
]
Alternatively, you can add the package directly via Xcode.
You can read the documentation here. Generated with jazzy. Hosted by GitHub Pages.
Documentation is also available on the Swift Package Index.
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
.
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! :)
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
.
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.
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
.
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.
- Implementing Modern Collection Views, Apple Dev Docs
- Updating Collection Views Using Diffable Data Sources, Apple Dev Docs
- Prefetching collection view data, Apple Dev Docs
- Building High-Performance Lists and Collection Views, Apple Dev Docs
- Make blazing fast lists and collection views, WWDC21
- Advances in diffable data sources, WWDC20
- Advances in UICollectionView, WWDC20
- Lists in UICollectionView, WWDC20
- Modern cell configuration, WWDC20
- Creating Lists with Collection View, Use Your Loaf
- Getting Started with
UICollectionViewCompositionalLayout
, Lickability - The Case for Lists in UICollectionView, PSPDFKit Blog
- CompositionalDiffablePlayground, Filip Němeček
Interested in making contributions to this project? Please review the guides below.
Also, consider sponsoring this project or buying my apps! ✌️
Created and maintained by Jesse Squires.
Released under the MIT License. See LICENSE
for details.
Copyright © 2019-present Jesse Squires.