A primitive component to make Chat-UI with stable prepending - no scroll jumps when loading older messages.
| Auto Scrolling | Prepending without jumps | Revealing Info |
|---|---|---|
![]() |
![]() |
![]() |
| Loading Indicator (top, bottom) | Typing Indicator |
|---|---|
![]() |
![]() |
Standard SwiftUI List and ScrollView cause scroll position jumps when prepending items. In chat apps, loading older messages creates jarring visual shifts as content is inserted above the current view.
A common workaround is adjusting contentOffset after prepending. However, this requires:
- Precise timing of when prepend operations complete
- Exact knowledge of inserted content height before layout
- Careful handling when multiple operations occur together (prepend + update + remove)
In practice, this approach breaks easily with complex data flows.
This library takes a different approach: a virtual content layout with a 100-million-point content space where items are anchored at the center. Prepending simply extends content upward without ever changing contentOffset, eliminating the timing and coordination problems entirely.
- Smooth Prepend/Append - No scroll jumps when loading older or newer messages
- UICollectionView-backed - Native recycling with SwiftUI cell rendering
- Self-Sizing Cells - Automatic height calculation for variable content
- Keyboard & Safe Area Handling - Automatic content inset adjustment for keyboard and safe areas
- Typing Indicator
- iOS 17.0+
- Swift 6.0+
- Xcode 26.0+
Add the following to your Package.swift file:
dependencies: [
.package(url: "https://github.com/FluidGroup/swiftui-messaging-ui", from: "1.0.0")
]Or add it through Xcode:
- File > Add Package Dependencies
- Enter the repository URL:
https://github.com/FluidGroup/swiftui-messaging-ui
import MessagingUI
import SwiftUI
struct Message: Identifiable, Equatable {
let id: Int
var text: String
var isFromMe: Bool
var timestamp: Date
}
// Define your cell using TiledCellContent protocol
struct MessageBubbleCell: TiledCellContent {
typealias StateValue = Void // No per-cell state needed
let item: Message
func body(context: CellContext<Void>) -> some View {
HStack {
if item.isFromMe { Spacer() }
Text(item.text)
.padding(12)
.background(item.isFromMe ? Color.blue : Color.gray.opacity(0.3))
.foregroundStyle(item.isFromMe ? .white : .primary)
.clipShape(RoundedRectangle(cornerRadius: 16))
if !item.isFromMe { Spacer() }
}
.padding(.horizontal)
}
}
struct ChatView: View {
@State private var dataSource = ListDataSource<Message>()
@State private var scrollPosition = TiledScrollPosition()
var body: some View {
TiledView(
dataSource: dataSource,
scrollPosition: $scrollPosition
) { message in
MessageBubbleCell(item: message)
}
.task {
dataSource.apply(initialMessages)
}
}
}Use prependLoader to handle loading older messages with a built-in loading indicator:
TiledView(
dataSource: dataSource,
scrollPosition: $scrollPosition,
prependLoader: .loader(perform: {
// Called when user scrolls near the top
let olderMessages = await fetchOlderMessages()
dataSource.prepend(olderMessages)
}) {
// Loading indicator shown while loading
ProgressView()
}
) { message in
MessageBubbleCell(item: message)
}Similarly, use appendLoader for loading newer messages at the bottom.
If you need to control the loading state externally:
@State private var isLoading = false
TiledView(
dataSource: dataSource,
scrollPosition: $scrollPosition,
prependLoader: .loader(
perform: { loadOlderMessages() },
isProcessing: isLoading
) {
ProgressView()
}
) { message in
MessageBubbleCell(item: message)
}@State private var scrollPosition = TiledScrollPosition()
// Scroll to bottom
Button("Scroll to Bottom") {
scrollPosition.scrollTo(edge: .bottom)
}
// Scroll to top
Button("Scroll to Top") {
scrollPosition.scrollTo(edge: .top, animated: false)
}iMessage-style horizontal swipe gesture to reveal timestamps. Use CellContext to access the reveal offset:
struct MessageBubbleCell: TiledCellContent {
typealias StateValue = Void
let item: Message
func body(context: CellContext<Void>) -> some View {
// Get the reveal offset with rubber band effect
let offset = context.cellReveal?.rubberbandedOffset(max: 60) ?? 0
HStack(alignment: .bottom, spacing: 8) {
if item.isFromMe {
Spacer()
// Timestamp fades in as user swipes
Text(item.timestamp, style: .time)
.font(.caption2)
.foregroundStyle(.secondary)
.opacity(offset / 40)
MessageBubble(message: item)
.offset(x: -offset) // Slide left to reveal
} else {
MessageBubble(message: item)
.offset(x: -offset)
Text(item.timestamp, style: .time)
.font(.caption2)
.foregroundStyle(.secondary)
.opacity(offset / 40)
Spacer()
}
}
}
}To disable the reveal gesture:
TiledView(...)
.revealConfiguration(.disabled)Show when other users are typing with the typingIndicator parameter:
TiledView(
dataSource: dataSource,
scrollPosition: $scrollPosition,
typingIndicator: .indicator(isVisible: store.isTyping) {
HStack(spacing: 8) {
TypingDotsView()
Text("Someone is typing...")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
) { message in
MessageBubbleCell(item: message)
}The typing indicator appears below the last message and automatically scrolls into view when it appears (if the user is near the bottom of the list).
Use onTiledScrollGeometryChange to observe scroll position changes. This is useful for showing "scroll to bottom" buttons or enabling/disabling auto-scroll:
@State private var showScrollButton = false
TiledView(...)
.onTiledScrollGeometryChange { geometry in
// Show button when user scrolls up from bottom
showScrollButton = geometry.pointsFromBottom > 100
// Dynamically enable auto-scroll only when near bottom
scrollPosition.autoScrollsToBottomOnAppend = geometry.pointsFromBottom < 100
}Configure automatic scrolling behavior for messaging UIs:
@State private var scrollPosition = TiledScrollPosition(
autoScrollsToBottomOnAppend: true, // Auto-scroll when new messages arrive
scrollsToBottomOnReplace: true // Start at bottom on initial load
)Manage UI state (like expanded/collapsed, tap counts) that persists across cell reuse using CellStateStorage. This is ideal for cell-specific state that isn't part of your data model.
Important: Why Not @State?
You can use
@Stateinside cell views, but the state will be lost when the cell is reused.TiledViewusesUICollectionViewwhich recycles cells for performance. When a cell scrolls off-screen and is reused for a different item, any@Statevalues are reset. UseCellStateStoragefor state that should persist.
struct MyCellState {
var isExpanded: Bool = false
var tapCount: Int = 0
}
struct MessageCell: TiledCellContent {
typealias StateValue = MyCellState
let item: Message
func body(context: CellContext<MyCellState>) -> some View {
VStack {
Text(item.text)
if context.state.value.isExpanded {
Text("Additional details...")
}
Button("Expand") {
context.state.value.isExpanded.toggle()
}
}
}
}
// Provide initial state factory
TiledView(
dataSource: dataSource,
scrollPosition: $scrollPosition,
makeInitialState: { item in MyCellState() }
) { message in
MessageCell(item: message)
}StateValue can be a reference type like @Observable classes for more complex state management:
@Observable
final class CellViewModel {
var isExpanded = false
var loadedData: Data?
func loadData() async { ... }
}
struct MessageCell: TiledCellContent {
typealias StateValue = CellViewModel
let item: Message
func body(context: CellContext<CellViewModel>) -> some View {
let viewModel = context.state.value
// Use viewModel directly - @Observable handles updates
Text(viewModel.isExpanded ? "Expanded" : "Collapsed")
}
}To share state across all cells (e.g., selection state), return the same instance in makeInitialState:
@Observable
final class SharedSelectionState {
var selectedIds: Set<Message.ID> = []
}
struct ChatView: View {
@State private var dataSource = ListDataSource<Message>()
@State private var scrollPosition = TiledScrollPosition()
@State private var sharedState = SharedSelectionState()
var body: some View {
TiledView(
dataSource: dataSource,
scrollPosition: $scrollPosition,
makeInitialState: { _ in sharedState } // Same instance for all cells
) { message in
SelectableMessageCell(item: message)
}
}
}- Creation: State is lazily created when a cell is first displayed
- Persistence: State persists for the lifetime of the
TiledView, even if items are temporarily removed - Destruction: State is released when the
TiledViewis destroyed
Note: If an item is removed and later re-added with the same ID, it will retain its previous state. This behavior may change in future versions with configurable lifecycle options.




