Writing

When SwiftUI stops being enough: rebuilding a chat message list in UIKit

Why a chat list in a shipping iOS app moved from ScrollView to a y-inverted UICollectionView: the scroll geometry SwiftUI won't give you, the four animation systems you have to silence separately, and a keyboard bug that took four attempts.

I build native iOS and macOS apps on contract. One of them has a chat screen: rows pinned to the newest message, a floating compose pill over the list, an interactively dismissable keyboard, top-edge pagination, deep-link jump-to-message, inline editing, and rows whose height changes while you look at them because replies stream in and blocks expand.

It shipped first as the obvious thing:

ScrollView {
    VStack {
        ForEach(messages) { MessageBubble }
    }
}

with a ScrollViewReader on top. That is the correct tool for a static list. It failed for this one, and not on polish: this is a scroll-geometry problem, and SwiftUI’s scrolling stack does not hand you scroll geometry. The replacement is a y-inverted UICollectionView behind a UIViewRepresentable: 13 files, about 1600 lines of Swift, plus a 284-line design doc. That is the honest price. None of what it bought shows in a screenshot; all of it shows the first time a thumb drags the list.

Three things the SwiftUI version could not do

Read or write scroll geometry: no contentOffset, no contentInset, no way to anchor a row’s edge across a mutation. The only lever is ScrollViewProxy.scrollTo(id:anchor:), which is a request, not a coordinate.

Contain animation: every scroll-position change went through withAnimation, and every content mutation inherited whatever transaction was in flight up the view tree.

Recycle: an eager VStack builds every row. No reuse pool, and no hook for “this row changed, re-measure just it.” All three show in the code that got deleted:

ForEach(visibleMessages) { message in
    MessageBubble(message: message /* ~14 more params */)
        .id(message.id)
        .onAppear {
            if message.id == visibleMessages.first?.id {
                Task { await loadOlderMessages() }
            }
        }
}

Pagination is an onAppear on the first row, so it fires on any recycle rather than at a scroll threshold. “Go to bottom” is a request against an id. And the scroll is wrapped in withAnimation(.easeOut(duration: 0.2)) because there is no other way to control it at all. None of it has a SwiftUI-side fix.

Inverting the list beats measuring it

Long conversations opened stranded mid-thread instead of at the newest message, sometimes with a gap below the last row.

“Scroll to the bottom” means “set the offset to contentSize.height - bounds.height”. But contentSize is not final until every cell has self-sized, and rows with async-parsed markdown and images resolve their heights over several layout passes on device. My first implementation waited a runloop and re-asserted the offset once, reading an under-measured contentSize. The second polled contentSize.height until it stabilised: a measurement loop pretending to be a layout rule. Both were races.

The fix deletes the problem instead of solving it. Invert the scroll view along Y so the newest message sits at content origin 0. Growing content then extends away from the resting offset, and a list parked at the bottom stays parked with no scroll call at all:

transform = CGAffineTransform(scaleX: 1, y: -1)
contentInsetAdjustmentBehavior = .never
automaticallyAdjustsScrollIndicatorInsets = false

The snapshot is built reversed so the newest row lands at index 0, while the row array stays oldest-to-newest everywhere else and first/last diff checks stay readable.

Then the trap. Once inverted, “the bottom” is not offset 0. The resting offset there is -adjustedContentInset.top, non-zero on every real conversation because the compose pill reserves space. Every “scroll to bottom” written as 0 landed a compose bar’s height off. The inverted view needs a vocabulary of upright coordinates, so no call site touches a physical offset again:

/// Points from the current offset to the visual bottom. 0 = parked at bottom.
var distanceFromVisualBottom: CGFloat { contentOffset.y + adjustedContentInset.top }

Inverting also inverts platform behaviour you did not write. scrollsToTop drives to offset 0, which is now the newest message, so the status-bar tap has to be intercepted and sent to the oldest end instead. Grep for every hardcoded 0.

Flip layers, not views

Each cell has to be flipped back, and where you apply the counter-flip decides whether Auto Layout measures real geometry or transformed geometry. cell.transform set in the registration closure gets stomped by UIKit’s post-configuration frame assignment, so the first cell rendered upside-down. contentView.transform survives, but it sits on the measurement path. The compositional list sizes the cell through contentView.systemLayoutSizeFitting, a flipped view distorts that, and the layout reserves more height than the content needs. That stranded difference is a gap below tall cells, growing in proportion to cell height, which I chased as a padding bug.

A layer transform changes rendered pixels without touching the geometry Auto Layout measures:

override func layoutSubviews() {
    super.layoutSubviews()
    if !CATransform3DEqualToTransform(contentView.layer.transform, Self.flip) {
        contentView.layer.transform = Self.flip
    }
}

Re-applying in layoutSubviews is idempotent, so it survives reuse, and the transform is stripped to identity around super.preferredLayoutAttributesFitting so the measurement pass never sees it.

Insets get mirrored by hand too, and there are two knobs where you assumed one. contentInsetAdjustmentBehavior governs contentInset only; automaticallyAdjustsScrollIndicatorInsets is a separate switch, defaults to true, and adds safeAreaInsets on top of whatever indicator insets you assign. Under the flip both put the nav-bar reservation on the visual bottom. SwiftUI gives you neither knob.

There is no single switch for “no animation”

The expensive part was never one animation. It was that four independent systems animate the same pixels, each needing its own suppression at its own layer:

SymptomSourceSuppression
New rows fade inInitial-appearance attributesperformWithoutAnimation
Cells breathe on scrollUIKit’s layout animation blockCATransaction.setDisableActions
Expand shoves siblingsIn-cell @State resizes asyncLift state out, reconfigure
Image swap cross-fadesInherited SwiftUI transactionKill the transaction’s animation
Row fades mid-streamStructural identity changedKeep the identity stable

animatingDifferences: false suppresses the diff’s position and height animation and nothing else. The compositional layout’s initial appearance attributes still fade a newly inserted cell in from alpha 0, and that needs the whole apply wrapped in UIView.performWithoutAnimation.

The reverse case is rows visibly animating their height as they enter the viewport. An entering cell gets an estimated height first, then the real one. apply(_ layoutAttributes:) runs on every affected cell inside UIKit’s own layout animation block, below anything performWithoutAnimation can reach. So you go one layer down:

override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {
    CATransaction.begin()
    CATransaction.setDisableActions(true)
    super.apply(layoutAttributes)
    CATransaction.commit()
}

Same intent, one layer lower, and only the lower one works. The last table row has no switch at all. A group of blocks rendered a plain card at one item and a Button at two or more, so a streaming tick crossing that boundary changed the view’s structural identity. SwiftUI correctly ran an insert-and-remove transition. Nothing to suppress. Root the body in Button unconditionally and use .disabled instead.

The keyboard took four attempts and one wrong diagnosis

The compose pill floats over the list, so the bottom reservation is composerHeight + keyboardOverlap, written as contentInset.top under the flip. Interactive dismiss means dragging the list drags the keyboard with your finger.

Writing that inset mid-gesture lurches the content. UIKit re-anchors contentOffset against the changed inset on the next pan tick, so the content moves the full inset delta while the bar moves only by what the gesture gave it. So the write gets stashed while the scroll is in motion. My guard covered isTracking only, so the terminal keyboardWillHide landed inside the release’s deceleration animation, shrank the reservation mid-flight, and settled the list a keyboard’s height behind the nav bar. In motion has to mean isTracking || isDecelerating.

Then UIKit posts a keyboard frame that is not a keyboard: once an interactive dismiss settles, a keyboardWillChangeFrame arrives carrying CGRect.zero. The overlap math read .zero’s minY as a keyboard top edge at y=0 and reserved the entire window. On an iPhone 17 Pro Max, window height 956pt, the reservation went 409pt to 1020pt and shoved every message behind the nav bar. Guarded, it settles 409pt to 98pt, and .zero resolves to the 34pt home-indicator strip:

guard isUp, let keyboardEndFrame, keyboardEndFrame.height > 0 else {
    return bottomSafeArea
}
return max(bottomSafeArea, windowBounds.maxY - keyboardEndFrame.minY)

Attempt three is the wrong turn. The stashed write has to be replayed when the gesture ends, and replaying it animated made the list jump up and slide back down. My diagnosis was “we are animating pixels that already arrived”, so I made the write unanimated. That removed the jump and introduced a snap: at lift-off the list teleported to its final offset in one frame while the keyboard was still visibly sliding.

Forty-five minutes later, the real mechanism. During an interactive dismiss the pan moves content and keyboard 1:1, so at lift-off the list is stranded by exactly the keyboard’s remaining travel. Measured on device, a ~210pt drag leaves 110.67pt, against a notification stating a real, non-zero duration proportional to what is left. The glide was right all along. What was wrong was a second thing inside the same block: setContentOffset issued inside UIView.animate is run by UIScrollView’s own animator, which ignores the block’s duration entirely. Tripling the duration left the motion frame-for-frame identical and moved only the completion out 3x. Two animators were fighting over contentOffset, and the offset animation was the jump. Only the reservation is yours to animate; shrinking it raises the floor of valid offsets and UIKit’s own clamp lands the list on the new visual bottom.

Where you replay it matters too: at lift-off, in scrollViewDidEndDragging, not after the coast. Otherwise a released flick holds a keyboard-sized reservation for the whole coast with the keyboard already gone, then moves the list that far the instant it stops. Animate the inset. Never the offset.

Owning the layout means owning the reentrancy

A cell resolving its real height above the viewport grows contentSize above the current offset, so UIKit slides the visible content to hold the absolute offset. Mid-drag that reads as a jump. selfSizingInvalidation = .disabled stops it list-wide: cells still resize on reconfigure, they just stop re-invalidating the layout themselves, and every height change becomes an explicit diffable reconfigure.

The price lands on the one cell hosting SwiftUI through UIHostingConfiguration, which can legitimately resize with no row-content change and now cannot re-measure itself either. Reporting its height back up naively earns you BUG_IN_CLIENT_OF_DIFFABLE_DATA_SOURCE__APPLYING_SNAPSHOTS_REENTRANTLY: onGeometryChange runs synchronously inside the hosted cell’s SwiftUI layout, inside layoutSubviews, which a diffable apply drives synchronously. Deduping on “is this height new?” does not help: the nested call reports a genuinely new height. The handler has to hop to the next runloop turn and coalesce per row id. Every apply call site funnels through one entry point owning an in-flight flag.

Apple’s guidance for UIHostingConfiguration cells is to leave selfSizingInvalidation enabled. I rejected it: the property is collection-view-wide, so enabling it for one SwiftUI cell re-enables it for every UIKit cell. I took the trade.

What shipped

  • A y-inverted UICollectionView, compositional list layout, diffable data source keyed on ids only. Cells resolve content from a dictionary on the collection view, so a reconfigure renders the latest row, not a stale snapshot instance.
  • Upright coordinate helpers, so no call site touches a raw physical offset.
  • A per-cell counter-flip as a layer transform, stripped to identity around the measurement pass.
  • One shared in-motion predicate gating auto-scroll on arrival, anchor restores and inset writes, so a user gesture always wins.
  • An identity early-return in updateUIView. The compose bar republishes its measured height on every keyboard frame, so SwiftUI re-ran the update every frame, reapplying an identical row set against a live gesture.
  • UIViewRepresentable, not UIViewControllerRepresentable: the latter nests the list in a UIHostingController, whose view rejects the scroll-effect anchor views SwiftUI inserts.
  • Everything that is not scroll geometry stays SwiftUI: loading and empty states, the compose pill, the scroll-to-bottom button, the edge fade, and all of the realtime, pagination and editing logic.

The transferable summary

  • SwiftUI’s scroll stack is a composition tool, not a geometry tool. The moment your requirement is written in points relative to an edge, you need UIScrollView.
  • Inverting a list deletes the measurement race instead of trying to win it, but it also inverts platform behaviour you did not write. Give the inverted view an upright vocabulary before anything calls it.
  • Flip layers, not views, whenever Auto Layout is measuring the thing you flip.
  • “Turn the animation off” is not one action. Work out which system owns the animation in front of you, then suppress it at that layer.
  • While a scroll view is animating, assume it owns contentOffset. Animate your insets and let its clamp land the offset.
  • Keep the UIKit island rendering-only. It holds no domain state, which is why 1600 lines of it stays maintainable.

This is the kind of work I take on contract: native iOS and macOS, the layer below the framework, the bugs that only reproduce on a real device with a real finger on the glass. If your list fights the keyboard, or your rows animate when you told them not to, I am at breno@brenoxp.com.

All posts