A Framework That Solved the Right Problem
We did not remove IGListKit because it had failed us. We removed it because the UIKit around it had changed.
Our document feed was not a flat list. It combined recent documents, folders, pinned content, metadata badges, permission states, cached results, pagination, loading indicators, and retry affordances. Several asynchronous inputs could change the visible list within the same run-loop turn.
When we first adopted IGListKit, native collection-view updates still required us to keep an array and a sequence of index-path mutations perfectly synchronized. IGListKit gave us a much safer model:
- Stable identity and explicit content equality through
ListDiffable. - Adapter-managed insertions, deletions, moves, and reloads.
- A section controller for each independently evolving feed component.
- Working-range callbacks for preparing content near the viewport.
- A consistent update model on versions of iOS that predated diffable data sources.
Those were meaningful improvements. They made a complicated feed easier to evolve and much less likely to crash during concurrent updates.
The decision changed after iOS 13. Once our deployment target could move forward, UIKit provided native answers to the two largest reasons we depended on IGListKit: safe identifier-based updates and composable, section-specific layouts. At that point, keeping the framework no longer removed an architectural layer. It added one.
This is a reflection on why we moved the feed back to UICollectionView, what we gained, and what we deliberately gave up.
The Feed Already Had One Source of Truth
Before discussing either UI framework, it is important to establish where feed state lived.
The collection view did not own documents, pagination, permissions, or cached results. Canonical application state did. Selectors transformed that state into an ordered presentation model containing:
- Stable section and item identifiers.
- The fields needed to render each visible item.
- Loading, retry, empty, and exhausted states.
- Content revisions for metadata-only changes.
The list layer received that projection and rendered it. User actions travelled in the opposite direction as application actions. They never mutated a cell array directly.
This architecture made the migration possible. We changed the renderer without changing the source of truth.
What IGListKit Gave Us
IGListKit's most important contribution was not an animation. It was an identity contract.
A visible object answered two separate questions:
- Is this the same object as before?
- If it is the same object, has its rendered content changed?
final class DocumentListItem: ListDiffable {
let id: String
let title: String
let subtitle: String?
let contentRevision: Int
func diffIdentifier() -> NSObjectProtocol {
id as NSString
}
func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
guard let other = object as? DocumentListItem else {
return false
}
return title == other.title
&& subtitle == other.subtitle
&& contentRevision == other.contentRevision
}
}
That contract stopped us from treating an index path as identity. A document could move between sections without becoming a different document, while a metadata change could refresh the existing item without replacing it.
Section controllers were equally valuable. A document row, folder card, pinned carousel, loading indicator, and retry panel could each own its sizing, cell construction, interaction, and display behavior. The feed controller did not need a growing switch statement for every possible component.
At the time, that was the right abstraction. The mistake would have been to forget that it was still an abstraction built around UICollectionView, with its own models, lifecycle, update coordinator, and conventions.
iOS 13 Changed the Native Baseline
Two UIKit additions changed the calculation in 2019.
Diffable data sources made identity native
UICollectionViewDiffableDataSource replaced manual index-path bookkeeping with snapshots of section and item identifiers. UIKit compared the current and next snapshots and performed the transition.
enum FeedSectionID: Hashable {
case pinned
case folders
case documents
case status
}
enum FeedItemID: Hashable {
case document(String)
case folder(String)
case loading
case retry(String)
}
func apply(_ projection: FeedProjection, animated: Bool) {
var snapshot = NSDiffableDataSourceSnapshot<FeedSectionID, FeedItemID>()
for section in projection.sections {
snapshot.appendSections([section.id])
snapshot.appendItems(section.itemIDs, toSection: section.id)
}
// The projection only includes reload IDs that still exist in the snapshot.
snapshot.reloadItems(projection.reloadedItemIDs)
dataSource.apply(snapshot, animatingDifferences: animated)
}
This was not identical to IGListKit. UIKit identifiers were Hashable, and content changes still required an explicit reload policy on iOS 13. But the core capability was now native: describe the next state with stable identifiers instead of manually coordinating array mutations and batch updates.
Compositional layout made heterogeneous sections native
UICollectionViewCompositionalLayout allowed each section to provide its own layout description. A vertical document list, a horizontal pinned-content strip, a folder grid, and full-width status panels could coexist in one collection view without a custom layout or nested scrolling hierarchy.
func makeLayout() -> UICollectionViewLayout {
UICollectionViewCompositionalLayout { [weak self] index, environment in
guard let section = self?.projection.sections[safe: index] else {
return nil
}
switch section.id {
case .pinned:
return self?.makePinnedSection(environment: environment)
case .folders:
return self?.makeFolderSection(environment: environment)
case .documents:
return self?.makeDocumentSection(environment: environment)
case .status:
return self?.makeStatusSection(environment: environment)
}
}
}
Together, diffable data sources and compositional layout covered the two most valuable pieces of our IGListKit integration: coordinated updates and modular section behavior. Collection-view prefetching already covered the main use case for our working-range callbacks.
Why the Extra Layer Started to Cost More Than It Saved
The migration was not driven by dependency count alone. It was driven by where complexity accumulated.
Framework types leaked into presentation models
To participate in IGListKit updates, presentation objects conformed to ListDiffable. That placed a rendering-framework protocol in types produced by our state projection. The view layer's update mechanism was no longer an implementation detail.
With a native diffable data source, the projection only needed stable Hashable identifiers and ordinary immutable view data. It could be tested without importing a third-party framework.
We had two update authorities
Canonical state and selectors already determined what the feed should show. ListAdapter then maintained another object graph and interpreted equality to decide how the collection view should change.
That separation was useful when UIKit offered no safe equivalent. Once diffable snapshots existed, it became duplicated coordination. Debugging a stale item meant tracing state, selector output, ListDiffable equality, adapter updates, and section-controller lifecycle. The native path reduced that chain to state, projection, snapshot, and renderer.
Section controllers multiplied indirection
Section controllers kept features modular, but not every feed element needed an object with its own lifecycle. Small status items accumulated boilerplate. Cross-cutting behavior such as selection, navigation, accessibility focus, layout invalidation, and interactive transitions often had to cross the adapter boundary or reach through to the underlying collection view.
We were still using UIKit, but sometimes through an additional vocabulary that made UIKit behavior harder to locate.
Working range duplicated our loading pipeline
Our state layer already knew which page was loaded, requested, failed, or exhausted. Collection-view prefetching could express proximity to upcoming items. Keeping working-range callbacks as another trigger created more paths capable of starting the same work.
The simpler rule was to let prefetching dispatch intent, then let the state pipeline deduplicate and accept results.
Maintenance had become visible product work
IGListKit 4.0 supported Swift, but its core remained Objective-C. Framework upgrades, bridging conventions, adapter-specific assertions, and workarounds for custom collection behavior all consumed time. None of these costs was individually severe. Together they mattered once UIKit provided the capabilities that had justified them.
The question changed from “Can we reproduce everything IGListKit does?” to “Which IGListKit responsibilities does this screen still need?” Our answer was identity, safe updates, modular rendering, flexible layout, and prefetching. UIKit could now provide all five, with a small application-owned boundary around rendering.
What Replaced the Section-Controller Architecture
Removing IGListKit did not mean moving every concern into one view controller. We kept the modularity and changed its shape.
Each feed feature provided two things:
- A renderer that dequeued and configured its cells.
- A compositional-layout section builder.
protocol FeedItemRendering {
func makeCell(
in collectionView: UICollectionView,
at indexPath: IndexPath,
item: FeedItem
) -> UICollectionViewCell
}
final class FeedRendererRegistry {
private let renderers: [FeedItem.Kind: FeedItemRendering]
func makeCell(
in collectionView: UICollectionView,
at indexPath: IndexPath,
item: FeedItem
) -> UICollectionViewCell {
guard let renderer = renderers[item.kind] else {
preconditionFailure("Missing renderer for \(item.kind)")
}
return renderer.makeCell(
in: collectionView,
at: indexPath,
item: item
)
}
}
The diffable data source's cell provider resolved an item identifier into immutable presentation data, then delegated to the registry. The view controller coordinated the pieces but did not implement every cell.
This preserved the best part of section controllers: feature-local rendering behavior. It removed the parts we no longer needed: framework-owned model protocols, adapter lifecycle, and one controller object per list object.
Details That Still Required Discipline
Moving to native UIKit did not make list architecture automatic.
Stable identity remained non-negotiable
Section and item identifiers had to be immutable and unique within a snapshot. We never used an array index, title, or object address as identity.
Identity and content were still different
A diffable snapshot describes structure through identifiers. On iOS 13, a visible item's title or badge could change without changing its identity, so the projection explicitly listed items that required reloadItems(_:).
Snapshot application had to be serialized
Cache restoration, remote refresh, metadata updates, and pagination could finish close together. We coalesced projections and applied snapshots on the main thread in order. A newer canonical projection superseded an older pending one.
The layout consumed the same projection
Layout and data could not independently interpret feed state. The section identifiers used by compositional layout came from the same ordered projection used to construct the snapshot.
Prefetching expressed intent, not ownership
UICollectionViewDataSourcePrefetching could request nearby document preparation or the next page. It did not append items directly. Requests still passed through the state layer, where duplicate work and stale responses were controlled.
These rules were not framework-specific. They were the architecture underneath a reliable feed.
How We Migrated Without Rewriting the Product
We moved one responsibility at a time.
- Freeze the identity contract. We documented stable section and item identifiers and added tests for duplicates, moves, and metadata-only changes.
- Remove
ListDiffablefrom projection types. Presentation models became plain immutable Swift values. Identity and content revisions remained explicit. - Build snapshots beside the existing adapter. For the same projection, we generated an IGListKit object list and a native diffable snapshot, then compared their order and identity in tests.
- Extract rendering from section controllers. Cell configuration moved into feature renderers. Layout decisions moved into compositional-layout section builders.
- Move working-range behavior to prefetching. Both paths dispatched the same state actions during the transition, allowing us to compare request behavior.
- Exercise native updates without animation. Cache restoration, pagination, permission removal, metadata reloads, empty states, and retry states ran through the new data source before visual transitions were enabled.
- Switch the screen and remove the adapter. Only after snapshot ordering, rendering, navigation, and accessibility behavior matched did we remove the old object graph.
The migration worked because business state, request ordering, and cache policy never belonged to IGListKit. We were replacing the final projection engine, not rebuilding the feed.
What We Gave Up
The native migration had real tradeoffs.
IGListKit still offered a mature diffing and update model on older versions of iOS. Its working-range API was convenient, and section controllers provided stronger per-object encapsulation than UIKit supplied by default. Teams with a lower deployment target or an established library of section controllers could reasonably reach a different conclusion.
Our decision became practical only when iOS 13 was an acceptable floor. Without diffable data sources and compositional layout, replacing the adapter would have recreated too much infrastructure locally.
We also took ownership of our renderer registry, snapshot reload policy, and update serialization. Native did not mean zero architecture. It meant that the small amount of architecture we owned matched our state model directly.
The Decision in Retrospect
IGListKit solved a real gap in UIKit and gave the feed a safer foundation at the right time. Adopting it was not a mistake.
By January 2020, however, the platform boundary had moved. iOS 13 made identifier-based updates and section-composed layouts first-class UIKit concepts. The framework's strongest capabilities were no longer unique, while its model protocols, adapter lifecycle, and section-controller graph remained an additional system to understand.
Moving back to UICollectionView gave the feed one visible update path:
canonical state → selector → projection → diffable snapshot → collection view
That alignment mattered more than comparing feature lists. It reduced the distance between application state and rendered UI, kept component boundaries under our control, and made failures easier to reason about using UIKit's own concepts.
We did not choose native because third-party frameworks are inherently risky. We chose it because the platform had absorbed the capabilities we needed, and the extra abstraction no longer earned its place.