Can we build a decent UICollectionView wrapper from SwiftUI, using the iOS 18
custom-container API? This lab's answer: yes — and it's better than
decent, because Subview proxies turn out to be first-class citizens:
renderable inside UIHostingConfiguration, addressable through container
values and tags, and durable enough that cell @State survives reuse.
CollectionUI/— the library (6 files, an iOS framework target).Chapters/— the textbook: ten chapter files, one use case each, prose in the header comment, runnable demo below. The app's table of contents is itself aCollectionView.Tests/— runtime probes in the wuhu-lab style: realUIWindow, real simulator, assertions about what actually happens.- This design stands on prior wuhu-app labs (presence, navigation-state, platform-environment), which are internal and not included here.
cd CollectionLab
xcodegen generate
open CollectionLab.xcodeproj # run the CollectionLab scheme
# headless chapter runs: simctl launch … dev.wuhu.collectionlab.CollectionLab -chapter 7
# probes:
xcodebuild -project CollectionLab.xcodeproj -scheme CollectionLab \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' testCollectionView resolves its content with Group(sections:) and hands the
SectionCollection to a representable. A diffable data source is keyed by
interned Int surrogates (diffable identifiers must be Sendable;
Subview.ID deliberately isn't). Each cell's contentConfiguration is a
UIHostingConfiguration hosting the item's Subview directly, with the live
context.environment injected at the root. Sections describe their layout
through container values (.collectionLayout(.grid(3)) on a Section), and
items describe their sizes the same way — the tag mechanism, used as a sizing
channel. A compositional section provider (or your own
UICollectionViewLayout, fed through collectionMetrics) turns those
declarations into UIKit layout, preferring absolute dimensions whenever the
declarations make sizes deterministic.
- Tags for preferred width/height — yes.
.collectionItemHeight(56),.collectionItemWidth(240),.collectionItemAspectRatio(3/4)are container values; they cascade fromSectionto items (probe-verified), and any deterministic size becomes an absolute dimension — the sizing negotiation never runs. Chapter 4; waterfall makes it structural in chapter 7. - A "size class" that measures once — yes.
.collectionSizingClass("card")measures the first member per (class, width, sizing environment) via an off-window pass over the same hosting configuration real cells use, then applies the exact size to every member. Dynamic Type changes invalidate automatically; 40 cards = 1 measurement (test-pinned). Chapter 5. - Complete custom layout — yes, two tiers.
.compositional { context in … }hands you the rawNSCollectionLayoutSectionwith a hint-aware context (chapter 8);CollectionView(layout: DeckLayout.init)accepts anyUICollectionViewLayout, which reads the SwiftUI-declared metrics throughcollectionView.collectionMetrics(chapter 9).
Empirical, on Xcode 26.4 / iOS 26.4 simulator / macOS 26.5 — observed behavior, not contracts:
| # | Finding | Where proven |
|---|---|---|
| 1 | .containerValue on a Section lands on SectionConfiguration.containerValues and cascades to content + header subviews; closest declaration wins |
macOS render probe |
| 2 | Loose content ahead of sections forms an implicit section | macOS render probe |
| 3 | .tag() reads back via containerValues.tag(for:) / hasTag |
macOS probe + testScrollToTagReachesItem |
| 4 | A value on ForEach stamps every generated subview, without leaking to the section |
macOS render probe |
| 5 | Subview renders correctly inside a cell's UIHostingConfiguration |
every runtime test |
| 6 | Reuse bounds live views: 2,000 rows ⇒ <60 live cells | testCellsMaterializeAndReuseBoundsLiveViews |
| 7 | Injected environment reaches cell content (custom key set outside the collection observed inside cells) | testEnvironmentReachesCells |
| 8 | Sizing class: 30 varied cards ⇒ exactly 1 measurement, pixel-identical heights | testSizingClassMeasuresOnceAndUnifiesHeights |
| 9 | Snapshot updates track SwiftUI state (8→20→3 items) | testSnapshotUpdateInsertsAndRemoves |
| 10 | onScrolledNearEnd fires once per content version |
testNearEndFiresOncePerContentVersion |
| 11 | @State inside a cell survives scroll-away + return — storage travels with the retained proxy across hosting-tree teardown |
testStateAcrossReuseVerdict (“SURVIVED”) |
| 12 | Diffable identifiers must be Sendable; Subview.ID/SectionConfiguration.ID aren't ⇒ interning table |
SDK swiftinterface |
- Finding 11 (and proxy durability generally) is platform behavior, per the presence lab's caveat — regression-test it, don't lean on it for critical state.
- Orthogonal (carousel) inner scroll offsets aren't addressable from outside;
scrollTo(tag:)reaches the section, not the card within. - Sizing-class caches don't watch the representative's content — that's the
contract;
proxy.invalidateSizingClasses()exists for redesigns. - Headers/footers are dequeued as
UICollectionViewCellsupplementaries (cells havecontentConfiguration; plain reusable views don't). - Selection, swipe actions, drag reorder: out of scope here; cells host fully interactive SwiftUI, which covers most of what those are used for.
- Your first CollectionView — reuse, self-sizing, pagination for free.
- Sections, headers, identity — pinned headers, diffable animations.
- Grids and flow — per-section layout dialects in one scroll.
- Size hints — the tag-cascade sizing channel; zero-measure cells.
- Sizing classes — measure once, share everywhere; Dynamic Type.
- Carousels — orthogonal sections;
scrollTo(tag:). - Waterfall — masonry as arithmetic over declared ratios.
- The escape hatch — raw
NSCollectionLayoutSection, hint-aware. - Bring your own layout —
UICollectionViewLayout+collectionMetrics. - Lifecycle & environment — the probe bench; what lives and dies in cells.
- The update tax — every content update walks all N (
rebuildEntriesis O(N), like theListmachinery it mirrors); measure per-update cost against N, and see per-cellText(style: .timer)tick for free because time routed inside the render server never becomes a content update.