Why Jar's SwiftUI Home Feed Scrolls on UIKit
12 min read · Written by Kartikeya Singh
Jar's new home feed is SwiftUI on screen and a UICollectionView underneath. Why LazyVStack couldn't carry it, and the five decisions that made SwiftUI cells scroll like native UIKit.
The brief was two sentences. Make the home feed scroll smoothly, and make a data update survive that scroll. I sat with it for 8–10 days before I wrote the collection view. Most of those days went into reading what others had learned: UIHostingConfiguration, compositional layout, and what List really is on iOS 16. Bringing UIKit into a screen we had just bet on SwiftUI felt like a step backwards. It was the step that gave the feed a scroll view I could drive, instead of one I had to go looking for.
Jar's new home screen has a full-screen Gold Locker header, and under it a persistent bottom sheet of SwiftUI sections from our Kotlin Multiplatform (KMM) core. The first version was a LazyVStack. It was slow in ways .equatable() never fixed. What replaced it is a UICollectionView whose cells host SwiftUI. The hard part was height, and telling an echo from a reload.
The TL;DR: SwiftUI draws every pixel of the feed; UIKit owns scrolling, reuse and sizing. Five decisions got us there, and the rest of this post is the reasoning behind each:
- 1
UICollectionViewoverLazyVStackandList: we needed the scroll view's delegate, not just its content. - 2 One collection-view section per feed section, with height estimates persisted across launches.
- 3 A
UIHostingControllerwe own per cell, notUIHostingConfiguration, so Lottie and nested carousels survive cell reuse. - 4 Synchronous layout invalidation when a hosted SwiftUI view grows in place.
- 5 Content-equality diffs feeding
reconfigureItemsandinsertSections.reloadDatais for structure changes only.
The feed under the locker
The backend describes the home feed as an ordered list of section types, and the shared core emits cards as [any DynamicFeatureTypeCard], each tagged with its section. Sections arrive from parallel fetches, pagination appends more, and a pull-to-refresh replaces everything.
Four demands shaped every decision below:
- The scroll has to hand off to the sheet. A downward drag at the top collapses the sheet, and a collapsed feed must not scroll at all. That needs the scroll view's
contentOffset,isScrollEnabledandscrollViewWillEndDragging, not aPreferenceKey. - Sections are heavy. Branded Lottie, video, nested carousels, gradient borders. Creating one during a fling is a dropped frame.
- Heights change in place. The Quick Save card expands on tap; a collapsed section is zero points tall.
- Data re-emits constantly. The shared layer regenerates card objects on every emission, even when nothing changed.
What LazyVStack refused to drop
The first feed was the SwiftUI you would write first, plus the hacks you add once it starts to struggle:
LazyVStack(spacing: 0) {
ForEach(sections, id: \.self) { section in
let index = sections.firstIndex(of: section) ?? 0
HomeFeedV2LazyItemView(
viewModel: viewModel,
dynamicCardsList: cards,
featureSectionType: section,
isSectionVisible: visibleIndices.contains(index),
shouldShowShimmer: shouldShimmer
)
.equatable()
// A zero-size UIView per section, so we can find the UIScrollView
// and measure how much of this section is on screen.
.background(SectionAnchorRepresentable(index: index, store: anchorStore))
.onAppear {
handlePaginationIfNeeded(currentIndex: index)
Task { @MainActor in evaluateVisibility(for: index) }
}
}
}
Every hack is SwiftUI reaching for a scroll view it doesn't own. And LazyVStack is lazy about creating sections, not about letting them go: a long session kept every Lottie and carousel the user had passed. The note we wrote when we gave up on it: "super laggy, hitchy, pagination freezes."
| Hangs / min | Time hung / min | |
|---|---|---|
| LazyVStack | 24.7 | 4.2s (6.9%) |
| List | 19 | 1.7s (2.8%) |
Neither is acceptable. A hang is any main-thread stall of 100 ms or more, and the target for a feed is zero. List does better because rows are reused instead of piling up; it still hangs, because a heavy section is built on first appearance either way.
Our first-generation home feed had used List, and it had the mirror-image problem. On iOS 16 and later a List is a UICollectionView, but not one you can reach: we were using SwiftUIIntrospect to find it, with no honest way to drive its delegate. If the scroll view is going to be a UICollectionView anyway, we'd rather own it.
List buys a little smoothness and pays for it in control, and a custom feed needs the control more. Reaching the scroll view is only the first thing it won't let you do:
- Row chrome fights the design. Insets, separators, backgrounds and selection all have to be switched off for edge-to-edge cards, and some never fully go.
- Nested scrolling is awkward. Carousels and pan gestures inside a row negotiate with
List's own gesture handling. - Heights are its business, not yours. No way to seed or persist an estimate, so the first-scroll jump comes back.
- Reuse rebuilds your representables. Lottie and video in a reused row start over, which the user sees as a stutter.
Decision 1 The column that decided it
We scored the four realistic options for any long feed of heavy SwiftUI sections. The scores are our judgment; the honest column is the last one before the average.
| Option | Performance | Scalability | Scroll control | Complex feed | Dev speed | Average |
|---|---|---|---|---|---|---|
| LazyVStack | 7 | 6 | 7 | 8 | 10 | 7.6 |
| List | 8 | 8 | 4 | 5 | 9 | 6.8 |
| UICollectionView | 10 | 10 | 10 | 10 | 5 | 9.0 |
| UIScrollView | 3 | 2 | 10 | 2 | 2 | 3.8 |
Scroll control decided it. The sheet, the walkthrough, and the visibility animations all want the real UIScrollView. A hand-rolled scroll view has that control and none of the cell machinery, which is why it scores last. Dev speed at 5 is accurate: about 870 lines, most of them about height.
Values in, SwiftUI out
The UIViewRepresentable takes values and closures, and nothing else. The view model never crosses into UIKit:
struct HomeFeedCollectionView: UIViewRepresentable {
let sections: [String]
let cardsBySection: [String: [any DynamicFeatureTypeCard]]
// Closures only. Holding the live VM here invites observation reads back
// into the representable, and the coordinator outlives any update cycle.
let onSwipeCompletion: (Double, Bool, CardEventData, HomeFeedSwipeCardLayoutActionData) -> Void
let walkThroughManager: HomeFeedV2WalkThroughManager
let shouldShimmer: Bool
let isScrollEnabled: Bool
let feedVersion: Int
let sectionVersions: [String: Int]
let onPagination: (Int) -> Void
let onPullDownToCollapse: (() -> Void)?
var onCollectionViewReady: ((UICollectionView) -> Void)?
// ...
}
The coordinator is the data source, the delegate and the prefetch data source. updateUIView compares three inputs against the previous ones, feedVersion, shouldShimmer and isScrollEnabled, and returns if none changed. SwiftUI can re-evaluate the parent body as often as it likes; the collection view doesn't hear about it.
One detail that pays for itself later: the reuse identifier is the section type.
func registerSections(_ sections: [String], in collectionView: UICollectionView) {
for section in sections where !registeredSections.contains(section) {
registeredSections.insert(section)
collectionView.register(HomeFeedCell.self, forCellWithReuseIdentifier: section)
}
}
A cell that once showed the rewards section will only ever be reused for the rewards section, so swapping rootView is a cheap diff of the same view types rather than a teardown of one card type and a build of another.
Decision 2 One guess cannot serve every row
The feed still jumped on the first scroll of a cold launch. The fix was the largest single change to the bridge, and it's easiest to see as a before-and-after:
| Before | After | Rating before | Rating after | |
|---|---|---|---|---|
| Layout | 1 section × N items, all at 320 pt | N sections × 1 item, own estimate | 1/5 | 5/5 |
| On self-size | Every row re-lays out. The jump travels. | Only that section corrects. | 1/5 | 5/5 |
| On emission | Pointer identity. Every echo reloads to 320 pt. | Content equality, per section. | 2/5 | 5/5 |
| 320 pt fallback | Every section, every time | Cold launch only | 2/5 | 4/5 |
The layout code is short. The sectionProvider closure runs once per section and reads the estimate from the store:
// One layout section per feed section lets sectionProvider supply per-section
// height estimates, eliminating contentSize corrections on first scroll.
UICollectionViewCompositionalLayout(
sectionProvider: { [weak coordinator] index, _ in
let sections = coordinator?.parent.sections ?? []
let estimated: CGFloat = index < sections.count
? (HomeFeedV2SectionHeightStore.shared.height(for: sections[index])
?? Coordinator.fallbackEstimatedHeight) // 320
: Coordinator.fallbackEstimatedHeight
let size = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(estimated)
)
let item = NSCollectionLayoutItem(layoutSize: size)
let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = .zero
return section
},
configuration: config
)
The store is a dictionary from section type to height, backed by UserDefaults, written at most once a second. It records zero for collapsed sections so the layout doesn't reserve 320 points for nothing.
The diff that made every echo look like a reload
Half of the first-scroll jump had nothing to do with layout. The snapshot diff that decides whether a KMM emission is a real change looked like this:
// Before
for i in scrollable.indices {
if scrollable[i].getSectionType != other.scrollable[i].getSectionType
|| scrollable[i].getCardType != other.scrollable[i].getCardType
|| scrollable[i] !== other.scrollable[i] { return true } // pointer identity
}
The shared layer allocates fresh card objects on every emission, so !== is true for an unchanged feed, and the flatMap order was not stable. Every echo became a reloadData, and every cell went back to 320 pt.
// After: group by section, compare content, never pointers.
func hasDifferentStructure(from other: HomeFeedV2Snapshot) -> Bool {
guard sections == other.sections else { return true }
return Self.groupsDiffer(scrollable, other.scrollable)
|| Self.groupsDiffer(nonScrollable, other.nonScrollable)
}
extension DynamicFeatureTypeCard {
func hasSameContent(as other: any DynamicFeatureTypeCard) -> Bool {
getSectionType == other.getSectionType
&& getCardType == other.getCardType
&& (self as AnyObject).isEqual(other as AnyObject) // Kotlin data-class equality
}
}
Kotlin data classes give us structural equals for free, and it comes across the KMM boundary as isEqual. We had it the whole time.
Decision 3 Keep the hosting controller
The first collection-view version used UIHostingConfiguration. It is Apple's purpose-built path for SwiftUI in cells, it self-sizes, and it is what we'd tell you to start with. We replaced it for one reason: scroll-back stutter.
Several sections host their own UIViewRepresentables: Lottie, and a carousel that is itself a scroll view. UIHostingConfiguration rebuilds that content when a cell comes back, so makeUIView runs again mid-gesture.
The fix was to own the hosting controller and never let it go:
final class HomeFeedCell: UICollectionViewCell {
private var hostingController: ContentHostingController?
func host(_ content: Content) {
if let existing = hostingController {
lastInvalidatedHeight = 0
existing.rootView = content // a SwiftUI diff, not a rebuild
return
}
let controller = ContentHostingController(rootView: content)
controller.sizingOptions = [.intrinsicContentSize]
controller.view.backgroundColor = .clear
controller.view.translatesAutoresizingMaskIntoConstraints = false
controller.onContentLayout = { [weak self] in
self?.reportContentHeightChangeIfNeeded()
}
hostingController = controller
contentView.addSubview(controller.view)
NSLayoutConstraint.activate([
controller.view.topAnchor.constraint(equalTo: contentView.topAnchor),
controller.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
controller.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
controller.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
}
}
Reuse is a rootView assignment. Nested representables get updateUIView, so Lottie keeps playing. We gave up child-view-controller containment and UIHostingConfiguration's automatic sizing. A cellKey skips the swap when nothing that the cell shows has changed.
Decision 4 SwiftUI knows the height. UIKit has to be told.
A compositional layout with .estimated heights asks each cell for its real size through preferredLayoutAttributesFitting. Ours answers in three tiers, cheapest first:
override func preferredLayoutAttributesFitting(
_ layoutAttributes: UICollectionViewLayoutAttributes
) -> UICollectionViewLayoutAttributes {
guard let hostingController else { return layoutAttributes }
// 1. SwiftUI has laid out: O(1) read, kept current by sizingOptions.
let intrinsic = hostingController.view.intrinsicContentSize
if intrinsic.height > 0 {
layoutAttributes.frame.size.height = intrinsic.height
recordHeight(intrinsic.height)
return layoutAttributes
}
// 2. Not yet laid out, but we measured this section type before.
if let type = sectionType,
let stored = HomeFeedV2SectionHeightStore.shared.height(for: type) {
layoutAttributes.frame.size.height = stored
return layoutAttributes
}
// 3. Cold: force a measurement. Expensive, and it makes SwiftUI evaluate the tree.
let measured = hostingController.view.systemLayoutSizeFitting(
CGSize(width: layoutAttributes.frame.width,
height: UIView.layoutFittingCompressedSize.height),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
)
layoutAttributes.frame.size.height = measured.height
recordHeight(measured.height)
return layoutAttributes
}
The tier that bit us is the one that isn't there: what happens when SwiftUI changes its mind after the cell is already on screen. The Quick Save card expands when tapped. SwiftUI grows the hosted view, the layout still holds the old height, and the next section draws on top of the expanded card. UIKit doesn't ask the cell again, because nothing told it to.
So we tell it. The hosting controller is subclassed for one override, and the cell turns each SwiftUI layout pass into a scoped invalidation:
final class ContentHostingController: UIHostingController<Content> {
var onContentLayout: (() -> Void)?
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard view.window != nil else { return }
onContentLayout?()
}
}
// In HomeFeedCell
private func reportContentHeightChangeIfNeeded() {
guard let hostingController else { return }
let height = hostingController.view.intrinsicContentSize.height
guard height > 0, abs(height - lastInvalidatedHeight) > 0.5 else { return }
lastInvalidatedHeight = height
recordHeight(height)
guard !isInvalidationScheduled else { return }
isInvalidationScheduled = true
defer { isInvalidationScheduled = false }
guard let collectionView = collectionView,
let indexPath = collectionView.indexPath(for: self) else { return }
let context = UICollectionViewLayoutInvalidationContext()
context.invalidateItems(at: [indexPath])
collectionView.collectionViewLayout.invalidateLayout(with: context)
}
That function is the third version; the first two were the "correct" thing to write, and both were wrong.
- Version 1Deferred with
async. The expand bounced one frame late. - Version 2
CADisplayLink. Better under scroll, still one frame late on expand. - Version 3Synchronous, with a re-entry guard. The bounce is gone.
Deferring a layout invalidation costs one frame, and one frame is the bounce. Invalidate synchronously, and guard re-entry.
Decision 5 An echo is not a reload
Once the diff is honest, the SwiftUI side commits a versioned snapshot: feedVersion increments only when structure differs, and a per-section counter bumps for each section whose cards changed. The coordinator turns a version bump into the least destructive UIKit operation that fits:
func handleDataChange(collectionView: UICollectionView, oldSections: [String],
newSections: [String], newParent: HomeFeedCollectionView) {
registerSections(newSections, in: collectionView)
// Same sections, content only: reconfigureItems keeps layout attributes
// and scroll position. Only cells whose cellKey changed are touched.
if newSections == oldSections {
parent = newParent
reconfigureChangedVisibleCells(in: collectionView)
return
}
let prefixUnchanged = oldSections.count > 0
&& newSections.count > oldSections.count
&& newSections.prefix(oldSections.count).elementsEqual(oldSections)
// Reorder or removal: the one case that earns a reloadData.
guard prefixUnchanged else {
parent = newParent
UIView.performWithoutAnimation { collectionView.reloadData() }
return
}
// Pagination appended sections: insert them.
UIView.performWithoutAnimation {
collectionView.performBatchUpdates {
// Swapped inside the batch so UIKit's before/after section counts agree.
self.parent = newParent
collectionView.insertSections(IndexSet(oldSections.count..<newSections.count))
}
}
}
Three paths, in order of how often they run:
- A content-only update, which is most of them, is a
reconfigureItemson the visible cells whosecellKeychanged; layout and scroll position are untouched. - A page appended by pagination is an
insertSections. - Only a reorder or removal gets a
reloadData, wrapped inperformWithoutAnimationbecause the alternative is a cross-fade of the whole feed.
The comment about swapping parent inside the batch is load-bearing. performBatchUpdates reads numberOfSections before running your block and again after; if the data source already reports the new count before the block, UIKit throws an internal inconsistency exception. The line is one assignment, and it took far longer to find than to write.
The sheet takes the flick
The reason we wanted the real scroll view fits in a few lines. The sheet's coordinator holds a weak reference to the collection view and owns three facts about it:
isScrollEnabledfollows whether the sheet is expanded, so a collapsed sheet's feed can't scroll under a drag.- While the sheet is animating closed, an
isCollapsingflag clampscontentOffsetto zero so deceleration can't fight the collapse. - The collection view's own delegate watches for the handoff gesture:
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>) {
// Flicked downward while already at the top: the sheet takes it from here.
guard velocity.y <= -0.5, scrollView.contentOffset.y <= 0 else { return }
parent.onPullDownToCollapse?()
}
Under LazyVStack this took a probe view, a found-by-traversal scroll view and a delegate swap. Here it's the delegate method it was always meant to be.
What we shipped
The collection view is the scroll engine of the new home feed: every section, the entrance animations, the walkthrough and pull-to-refresh all ride on it.
Two is not zero, and zero is the target, but the shape is the point: LazyVStack's sixteen hangs are spread through the whole scroll; the collection view's two are the landing commit and the pull-to-refresh.
The result is a feed that feels finished: smooth from the first fling after a cold launch to the last paginated section. Same test, same tooling: sixteen hangs became two, and none of them while scrolling.
What a feed has to survive
- Pick the container by the control you need, not the code you'd rather write. If your feed has to negotiate with a sheet, a header or a walkthrough,
LazyVStackwill cost you the hacks we deleted. - Own the hosting controller if cells contain representables, and give the layout one section per feed section so estimates have somewhere to live.
- Diff by content, invalidate synchronously. Object identity means nothing when the other side re-allocates on every emission, and every deferred invalidation is one frame late.
The feed looks like the design we were handed. It scrolls like a UIKit table view, because underneath the SwiftUI it is one.