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.

16 → 2hangs per run, LazyVStack to UICollectionView
2.7 s → 0.3 sof the scroll spent frozen, 6.9% to 0.6%
906 → 201 mslongest hang, now only at landing
Left: a phone buried under every feed card it has ever shown. Right: the same phone holding four cards, with four empty card outlines circulating around it.
LazyVStackEvery section stays mounted.
UICollectionViewA small set of cells, reused.
LazyVStack is lazy about creating sections, not about letting them go. A collection view keeps only what is on screen plus a buffer, and reuses the cells.

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. 1 UICollectionView over LazyVStack and List: we needed the scroll view's delegate, not just its content.
  2. 2 One collection-view section per feed section, with height estimates persisted across launches.
  3. 3 A UIHostingController we own per cell, not UIHostingConfiguration, so Lottie and nested carousels survive cell reuse.
  4. 4 Synchronous layout invalidation when a hosted SwiftUI view grows in place.
  5. 5 Content-equality diffs feeding reconfigureItems and insertSections. reloadData is 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, isScrollEnabled and scrollViewWillEndDragging, not a PreferenceKey.
  • 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."

LazyVStack vs List LazyVStack from one 38.9 s run from app launch on a debug build, per minute of scrolling. List from our earlier List-vs-LazyVStack runs on this feed, on the same basis. A hang is a main-thread stall of 100 ms or more.
Hangs / minTime hung / min
LazyVStack24.74.2s (6.9%)
List191.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.

What could scroll a long SwiftUI feed Scores out of 10 for a long feed of heavy SwiftUI sections
OptionPerformanceScalabilityScroll controlComplex feedDev speedAverage
LazyVStack7678107.6
List884596.8
UICollectionView1010101059.0
UIScrollView3210223.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

KMM StateFlow Feed view model Snapshot commit content diff feedVersion +1 UICollectionView Coordinator N sections × 1 item HomeFeedCell UIHostingController rootView swap only Section view SwiftUI emits observe values dequeue host PersistentBottomSheet SheetScrollDelegate isScrollEnabled pull-down → collapse SectionHeightStore UserDefaults, per section estimates → sectionProvider records measured height
The height store is the loop between what SwiftUI measured last time and what the layout estimates this time.

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:

LazyVStack keeps every card until the pile lands on them and hangs. UICollectionView keeps a window of cards and lets one slide out as another slides in.
LazyVStackEvery card stays. Then it hangs.
UICollectionViewA window of cards, reused.
LazyVStack keeps every row it has shown. The pile lands, and the scroll hangs. A collection view keeps a window and lets one card leave as another comes in.
One guess, then a door per section Before and after. A stale stored height still only corrects that one section, so the fallback is 4/5.
BeforeAfterRating beforeRating after
Layout1 section × N items, all at 320 ptN sections × 1 item, own estimate1/55/5
On self-sizeEvery row re-lays out. The jump travels.Only that section corrects.1/55/5
On emissionPointer identity. Every echo reloads to 320 pt.Content equality, per section.2/55/5
320 pt fallbackEvery section, every timeCold launch only2/54/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 2CADisplayLink. 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 reconfigureItems on the visible cells whose cellKey changed; layout and scroll position are untouched.
  • A page appended by pagination is an insertSections.
  • Only a reorder or removal gets a reloadData, wrapped in performWithoutAnimation because 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:

  • isScrollEnabled follows whether the sheet is expanded, so a collapsed sheet's feed can't scroll under a drag.
  • While the sheet is animating closed, an isCollapsing flag clamps contentOffset to 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.

LazyVStack vs UICollectionView Same feed, same tooling, one run each, both recorded from app launch on debug builds. A hang is a main-thread stall of 100 ms or more.
Hangs per run16→224.7 a minute became 2.5
Time hung2.70 s→0.31 s6.9% of the scroll became 0.6%
Longest hang906 ms→201 msand it is the landing, not the scroll
SwiftUI for the view. UIKit for the container.

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, LazyVStack will 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.

Related Articles

Migrating Jar's home screen to @Observable

How we moved our busiest screen off ObservableObject — one migration, nine state objects, and 26% fewer view-body invocations for the same pixels.

Journey of Reducing iOS App Size by 63%

Every mechanism, number, trap and dead end from taking the Jar iOS bundle from 460 MB to 170 MB: measurement, the size report, 20 levers, Kotlin/Native anatomy, and the two-package-manager crash-report loss.

Understanding and Eliminating Unnecessary SwiftUI View Recompositions

Eliminated long-running view body updates across multiple SwiftUI screens—improving frame stability, reducing CPU load, and preventing dropped frames and UI stutter during interactions and animations.