swiftcnearly preview
Component library

Message Scroller

Native message scroller composition, package imports, and editable Swift source.

Preview

Message Scroller rendered by native SwiftUI.Message Scroller rendered by native SwiftUI. Dark appearance.
Captured from the native SwiftUI catalog on macOS.

Use it

Import SwiftCN to use CNMessageScroller directly. This complete example also runs in the native component gallery.

MessageScrollerExample.swift
import SwiftUI
import SwiftCN

struct MessageScrollerExample: View {
    private struct Message: Identifiable {
        let id: Int
        var text: String
    }
    @State private var messages = (1...12).map { Message(id: $0, text: "Message \($0): a stable native scroll target.") }
    @State private var position: Int?
    @State private var follow = false
    @State private var atBottom = false
    @State private var revision = 0
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            CNMessageScroller(messages, followNewMessages: follow, scrollRevision: revision,
                              position: $position, isAtBottom: $atBottom, classes: "h-[240]") { message in
                CNBubble { Text(message.text) }
            }
            CNCheckbox("Follow latest messages", isOn: $follow)
            CNButtonGroup {
                CNButton("Earlier", variant: .outline) {
                    let first = messages.first?.id ?? 1
                    messages.insert(contentsOf: ((first - 5)..<first).map { Message(id: $0, text: "Earlier message \($0)") }, at: 0)
                }
                CNButton("Append", variant: .outline) {
                    messages.append(Message(id: (messages.last?.id ?? 0) + 1, text: "A new message."))
                }
                CNButton("Stream", variant: .outline) {
                    guard !messages.isEmpty else { return }
                    messages[messages.count - 1].text += " More streamed text, wrapping naturally as the message grows."
                    revision += 1
                }
            }
            Text(atBottom ? "At the latest message" : "Reading history — position stays under your control")
                .tw("text-xs text-mutedForeground")
        }
    }
}

Native behavior

A lazy native scroll view uses stable message IDs and scroll targets. Bind position to restore a reading target and retain it when earlier history is prepended. Following defaults to false and no initial bottom jump is forced. Set followNewMessages to true to follow appends and viewport changes; increment scrollRevision when streamed text changes height. Streaming scroll updates disable competing animations. Bind isAtBottom to observe proximity to the end and implement your host policy. The host owns keyboard placement, loading, and following decisions. On iOS 18 and macOS 15 or later, native scroll geometry reports end-boundary changes. Older systems retain the sentinel fallback. Following resumes after native scroll targets register; stable item IDs and the saved pixel offset preserve paused reading.

Style and compose

Visual recipes use .tw classes. Append classes: to override a recipe locally, or change the recipe in TWGlobalRules.named for your subtree. Native layout and interaction modifiers remain SwiftUI APIs. For control colors, append cn-tint-[primary], another theme token, or an interpolated Color.

Motion recipes are editable too: control-motion uses a 150 ms smooth response, disclosure-motion uses a 250 ms spring, and feedback-motion uses a 200 ms smooth response. Set a recipe to animate-none in your global rules to turn it off. Presets respect Reduce Motion. Static components stay still; native scrolling, drag tracking, and platform presentation keep their own gestures.

Action groups and card, dialog, and questionnaire footers stack when their content does not fit. The native layout preserves the same child controls during this change.

View builders preserve native child content. Native Button roles, labels, keyboard shortcuts, submit actions, and presentation modifiers still work. A string recipe does not replace a native control API or a typed chart mark.

Own the source

Copy the complete file below into your app alongside import SwiftCN. The file contains related composable parts from the same family. Its dependencies use public package APIs, so copied and imported components can coexist.

To own the styling engine too, download the complete source bundle. It contains every component, the .tw engine, its recipes, and the MIT license. Keep those files in one module and remove import SwiftCN from the usage example.

Scrolling.swift
import SwiftUI
import SwiftCN

public struct CNScrollArea<Content: View>: View {
    private let axes: Axis.Set
    private let indicators: Bool
    private let classes: TWClasses
    private let content: Content
    public init(_ axes: Axis.Set = .vertical, showsIndicators: Bool = true, classes: TWClasses = "", @ViewBuilder content: () -> Content) {
        self.axes = axes; indicators = showsIndicators; self.classes = classes; self.content = content()
    }
    public var body: some View { ScrollView(axes, showsIndicators: indicators) { content }.tw(cn("scroll-area", classes)) }
}
/// Stable native scroll targets preserve reading position when history is prepended.
/// The host owns following policy. Bind isAtBottom to observe proximity to the end.
public struct CNMessageScroller<Data: RandomAccessCollection, Content: View>: View where Data.Element: Identifiable {
    private let data: Data
    private let followNewMessages: Bool
    private let scrollRevision: Int
    private let position: Binding<Data.Element.ID?>?
    private let bottomState: Binding<Bool>?
    private let classes: TWClasses
    private let content: (Data.Element) -> Content
    @State private var localPosition: Data.Element.ID?
    @State private var bottomID = UUID()
    @State private var readingAnchor = CNMessageReadingAnchor<Data.Element.ID>()
    public init(_ data: Data, followNewMessages: Bool = false, scrollRevision: Int = 0,
                position: Binding<Data.Element.ID?>? = nil, isAtBottom: Binding<Bool>? = nil,
                classes: TWClasses = "", @ViewBuilder content: @escaping (Data.Element) -> Content) {
        self.data = data; self.followNewMessages = followNewMessages; self.scrollRevision = scrollRevision
        self.position = position; bottomState = isAtBottom; self.classes = classes; self.content = content
    }
    public var body: some View {
        GeometryReader { viewport in
            let viewportHeight = viewport.size.height
            ScrollViewReader { proxy in
                ScrollView {
                    LazyVStack(alignment: .leading, spacing: 8) {
                        ForEach(data) { item in
                            content(item).id(item.id).background {
                                if !followNewMessages && item.id == (position?.wrappedValue ?? localPosition) {
                                    Color.clear.onGeometryChange(for: CNMessageReadingFrame.self, of: {
                                        let frame = $0.frame(in: .scrollView(axis: .vertical))
                                        return CNMessageReadingFrame(minY: frame.minY, height: frame.height)
                                    }) { frame in
                                        guard readingAnchor.frame == nil || readingAnchor.firstID == data.first?.id else { return }
                                        readingAnchor.firstID = data.first?.id
                                        readingAnchor.targetID = item.id
                                        readingAnchor.frame = frame
                                    }
                                }
                            }
                        }
                    }.scrollTargetLayout()
                    Color.clear.frame(height: 1)
                        .id(bottomID)
                        .onGeometryChange(for: CNMessageEndGeometry.self, of: {
                            CNMessageEndGeometry(bottom: $0.frame(in: .scrollView(axis: .vertical)).maxY,
                                                 height: $0.bounds(of: .scrollView(axis: .vertical))?.height ?? viewportHeight)
                        }) { end in
                            // Newer systems observe the native scroll geometry instead of this sentinel.
                            if #available(iOS 18, macOS 15, *) { return }
                            updateEnd(CNMessageEndState(bottom: end.bottom, height: end.height), proxy: proxy)
                        }
                }
                .modifier(CNMessageEndObserver { updateEnd($0, proxy: proxy) })
                .scrollPosition(id: position ?? $localPosition, anchor: followNewMessages ? .bottom : .top)
                .defaultScrollAnchor(followNewMessages ? .bottom : (position?.wrappedValue == nil ? .top : nil))
                .tw("scroll-area")
                .onAppear {
                    if followNewMessages { scrollToLatest(proxy) }
                    else { restoreReadingTarget(proxy, height: viewportHeight) }
                    readingAnchor.hasAppeared = true
                }
                .task(id: followNewMessages) {
                    // Older SwiftUI versions register scroll targets after appearance.
                    await Task.yield()
                    guard !Task.isCancelled else { return }
                    if followNewMessages { scrollToLatest(proxy) }
                    else if readingAnchor.initialRestorationPending { restoreReadingTarget(proxy, height: viewportHeight) }
                    readingAnchor.initialRestorationPending = false
                }
                .onChange(of: data.first?.id) { _, _ in
                    if followNewMessages { scrollToLatest(proxy) }
                    else { restoreReadingTarget(proxy, height: viewportHeight) }
                    readingAnchor.firstID = data.first?.id
                }
                .onChange(of: scrollRevision) { _, _ in if followNewMessages { scrollToLatest(proxy) } }
                .onChange(of: data.last?.id) { _, _ in if followNewMessages { scrollToLatest(proxy) } }
                .onChange(of: viewport.size) { _, _ in if followNewMessages { scrollToLatest(proxy) } }
            }
        }.tw(classes)
    }
    private func updateEnd(_ end: CNMessageEndState, proxy: ScrollViewProxy) {
        if let bottomState, bottomState.wrappedValue != end.isAtBottom { bottomState.wrappedValue = end.isAtBottom }
        if followNewMessages && end.needsFollowing { scrollToLatest(proxy) }
    }
    private func restoreReadingTarget(_ proxy: ScrollViewProxy, height: CGFloat) {
        guard let target = position?.wrappedValue ?? localPosition else { return }
        let frame = readingAnchor.hasAppeared && !readingAnchor.initialRestorationPending && readingAnchor.targetID == target ? readingAnchor.frame : nil
        let available = height - (frame?.height ?? 0)
        let anchor = UnitPoint(x: 0.5, y: available > 1 ? (frame?.minY ?? 0) / available : 0)
        var transaction = Transaction(); transaction.disablesAnimations = true
        withTransaction(transaction) { proxy.scrollTo(target, anchor: anchor) }
    }
    private func scrollToLatest(_ proxy: ScrollViewProxy) {
        guard !data.isEmpty else { return }
        // Streaming and keyboard layout changes must not enqueue competing animations.
        var transaction = Transaction(); transaction.disablesAnimations = true
        withTransaction(transaction) { proxy.scrollTo(bottomID, anchor: .bottom) }
    }
}
// One visible target stores its pixel offset without invalidating body during scrolling.
@MainActor private final class CNMessageReadingAnchor<ID> {
    var hasAppeared = false
    var initialRestorationPending = true
    var firstID: ID?
    var targetID: ID?
    var frame: CNMessageReadingFrame?
}
// Only these two values are needed. Avoid exposing CGRect's SDK conformance in the opaque body type.
private struct CNMessageReadingFrame: Equatable {
    let minY: CGFloat
    let height: CGFloat
}
private struct CNMessageEndGeometry: Equatable {
    let bottom: CGFloat
    let height: CGFloat
}
// Native geometry emits only boundary changes, rather than every scrolling pixel.
private struct CNMessageEndState: Equatable {
    let isAtBottom: Bool
    let needsFollowing: Bool
    init(bottom: CGFloat, height: CGFloat) {
        isAtBottom = bottom <= height + 24 && bottom >= 0
        needsFollowing = bottom.isFinite && bottom > height + 1
    }
}
private struct CNMessageEndObserver: ViewModifier {
    let update: (CNMessageEndState) -> Void
    func body(content: Content) -> some View {
        if #available(iOS 18, macOS 15, *) {
            content.onScrollGeometryChange(for: CNMessageEndState.self, of: { geometry in
                CNMessageEndState(bottom: geometry.contentSize.height + geometry.contentInsets.bottom - geometry.contentOffset.y,
                                  height: geometry.containerSize.height)
            }) { _, value in update(value) }
        } else {
            content
        }
    }
}
/// Native scroll paging with a binding to the stable item ID. Size the viewport with classes.
public struct CNCarousel<Data: RandomAccessCollection, Content: View>: View where Data.Element: Identifiable {
    private let data: Data
    @Binding private var selection: Data.Element.ID?
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    private let classes: TWClasses
    private let content: (Data.Element) -> Content
    public init(_ data: Data, selection: Binding<Data.Element.ID?>, classes: TWClasses = "",
                @ViewBuilder content: @escaping (Data.Element) -> Content) {
        self.data = data; _selection = selection; self.classes = classes; self.content = content
    }
    public var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 0) {
                ForEach(data) { item in content(item).containerRelativeFrame(.horizontal).id(item.id) }
            }.scrollTargetLayout()
        }.scrollTargetBehavior(.paging).scrollPosition(id: $selection).tw(cn("carousel", classes))
            .onChange(of: data.map(\.id), initial: true) { _, ids in
                if let selection, !ids.contains(selection) { self.selection = ids.first }
            }
            .accessibilityAction(named: Text("Next page")) { move(1) }
            .accessibilityAction(named: Text("Previous page")) { move(-1) }
    }
    private func move(_ delta: Int) {
        let ids = data.map(\.id)
        guard !ids.isEmpty else { return }
        let index = selection.flatMap { ids.firstIndex(of: $0) } ?? 0
        withAnimation(reduceMotion ? nil : .smooth(duration: 0.25)) {
            selection = ids[min(ids.count - 1, max(0, index + delta))]
        }
    }
}

On this page