THE PROBLEM

You're reading an old message. You scroll up, another page of history loads, and the message you were reading is suddenly somewhere else on the screen. Almost every chat app has fought this bug.

The cause is simple. A scroll view remembers its position as a single number, contentOffset.y: the distance from the top of the content to the top of the screen. Insert older messages at the top and every existing row is pushed down, but that number stays the same. It now points at the new rows.

Side by side: in most lists an older page lands at the top and shoves the message you are reading off screen; in Steady the page lands above the screen and nothing moves.

The usual fixes all correct the jump after it happens:

  • Measure, insert, shift back: Move the offset down by the inserted height. It works until a cell resizes, an image loads, or a second page lands mid-fling. Then the correction races the layout and the list twitches.
  • Flip the list upside down: A prepend becomes an append, so it no longer jumps. But context menus and insert animations now have to be un-flipped by hand.
  • SwiftUI's scrollPosition(id:): Restores the position by row identity. In a lazy stack the rows above haven't been measured yet, so the restore is built on estimated heights.

THE IDEA

Steady doesn't correct the scroll position. It makes sure there is never anything to correct.

The list lives on a canvas that is 1,000,000 points tall and never grows. The first message is pinned at its middle. Older messages are laid out upward from there, newer ones downward. The empty canvas above and below is hidden behind negative content insets, so you can only scroll where messages actually exist.

When an older page arrives, its rows are placed in the empty space above the first message. Existing rows keep their exact coordinates, the top inset shrinks to reveal the new rows, and the scroll offset is never touched. The message you're reading has no reason to move.

A curtain hides the empty wall above the chat. It lifts, three older messages are pinned onto the bare wall, and the phone screen below never moves.

IN ACTION

THE EXAMPLE APP

Scrolling back through a long conversation while older pages stream in from a mock server. Each page lands above the screen and the message under your thumb stays exactly where it was. No flicker, no snap back.

The example app ships in the repo, so anyone evaluating the package can run it and try to break it.

SEE IT STEP BY STEP

An interactive breakdown I built to explain the bug and the fix. It compares three approaches: a plain ScrollView, SwiftUI's scrollPosition(id:), and Steady's fixed canvas. The orange frame is the phone screen and everything behind it is the scroll view's content, drawn in points. Step through each tab and watch the msg-0 moved on screen readout.

Open the demo full screen ↗

HOW IT WORKS

About 750 lines of Swift. The no-jump guarantee lives in one custom layout; everything else is plumbing around it.

A FIXED CANVAS

  • Constant Content Size: A custom UICollectionViewLayout always reports 1,000,000 pt of height, so the scroll view never sees its content grow.
  • Anchor-Relative Rows: Each row is stored relative to an anchor at the canvas middle. Older rows get negative positions and newer rows positive ones.
  • Hidden Empty Space: Negative contentInset on both sides trims the canvas down to exactly the loaded messages, so scrolling stops at the oldest and newest rows.

PREPEND WITHOUT TOUCHING THE OFFSET

  • The Right Hook: Inserts are placed in prepare(forCollectionViewUpdates:), the only point in a layout pass that knows whether new items went at the top or the bottom.
  • Grow Upward: Prepended rows stack upward from the old first row. Appended rows stack downward under the last one. Rows already on screen keep their frames.
  • UIKit's One Surprise: When the top inset changes, UIScrollView nudges the offset to "keep content in place". Here the content never moved, so that nudge would itself be a jump. Steady puts the offset back.
// Prepends: walk upward from the old first item.
var y = result[firstOld].y
for index in stride(from: firstOld - 1, through: 0, by: -1) {
    y -= result[index].height
    result[index].y = y
}

PAGING THAT CAN'T LOOP

  • Prefetch Early: onNeedsOlder fires when you scroll into the top quarter of the loaded history, so the next page is usually ready before you reach it.
  • One Request at a Time: A small pager state machine (idle, loading, exhausted, failed) keeps one request in flight. An empty page marks the history as exhausted.
  • Safe Overlaps: Items are de-duplicated by id, so a page that repeats messages can't crash the data source or show doubles.
  • No Bounce Back: In a list that jumps, the user lands back at the top after each load, which triggers the next load, and so on. Since nothing moves here, that loop can't start.

BUILT FOR REAL CHAT SCREENS

  • Send Animation: append(_:from:) flies a sent message in from the composer's frame, over the rows sliding up beneath it.
  • Server Echoes: When the server returns a message with the same id, the row updates in place instead of appearing twice.
  • Context Menus: Long-press menus come from contextMenuProvider, and cells can lift just the bubble as the preview.
  • Cached Heights: Each height is asked for once per item per width and cached by id. Rotation re-measures without shifting the canvas.

USING THE PACKAGE

Steady owns layout and scroll stability. The app keeps full control of cells, heights and data. Add it with Swift Package Manager:

.package(
    url: "https://github.com/RunTerror/Steady.git",
    from: "0.1.0"
)

Then supply cells, heights and a way to fetch older pages. The list opens at the newest message and asks for more as you scroll back:

import Steady

let chat = ChatListViewController<Message>()

chat.cellProvider = { collectionView, indexPath, message in
    collectionView.dequeueConfiguredReusableCell(
        using: registration, for: indexPath, item: message)
}

chat.heightProvider = { message, width in
    MessageCell.height(for: message, width: width)
}

chat.onNeedsOlder = { oldest in
    Task { @MainActor in
        chat.prepend(await api.page(before: oldest.id))
    }
}

chat.setItems(await api.latest())

For SwiftUI, wrap ChatListViewController in a UIViewControllerRepresentable. The example app does exactly that.

TRADE-OFFS

Steady is an early release, and the design makes some deliberate trades:

  • Finite Canvas: Room for roughly 5,000 messages in each direction from where the conversation opened.
  • Explicit Heights: The app supplies each row's height, and it must match the rendered cell. That is the price of never measuring after layout.
  • Scrollbar: The indicator reflects loaded messages, not the whole history, so it is hidden by default.
  • Not Yet: Deleting messages, edits that change a row's height, Dynamic Type changes and pagination error handling are still open work.

TECHNICAL APPROACH

Custom Collection View Layout

Fixed 1M-point canvas, anchor-relative row metrics, and negative insets

Diffable Data Source

Snapshots keyed by id; generic over any Identifiable message type

Pager State Machine

One request in flight, with exhausted and failed states

Swift Package

Swift 6.2 tools, main-actor isolated by default, iOS 17 and later

View on GitHub