ConversationKit is a SwiftUI library that provides an elegant and easy-to-use chat interface for iOS applications. It offers a complete solution for building conversational UIs with support for text messages, images, markdown rendering, and seamless integration with AI services.
- 💬 Ready-to-use chat interface with built-in message bubbles
- 👤 Multi-participant support (user vs other)
- 🖼️ Image message support with async loading
- 📝 Markdown rendering for rich text messages
- ⚡️ Async/await support for message handling
- 🎨 Customizable message rendering with custom content closures
- 📱 Modern iOS design with glass effects (iOS 17+)
- 🔄 Real-time message streaming support
- 📎 Attachment actions with customizable menu
- 🎯 Gemini-style "Push and Pin" scrolling (native SwiftUI clamping logic)
- ⚙️ Progressive disclosure APIs for custom actions and disclaimers
- 🛑 Interruptible generation (built-in stop button support)
- iOS 17.0+
- Swift 5.10+
- Xcode 15.0+
Add ConversationKit to your project using Swift Package Manager:
dependencies: [
.package(url: "https://github.com/peterfriese/ConversationKit", from: "1.0.0")
]import SwiftUI
import ConversationKit
struct ChatView: View {
@State private var messages: [DefaultMessage] = []
var body: some View {
NavigationStack {
ConversationView(messages: $messages)
.onSendMessage { userMessage in
// Handle the sent message asynchronously
await processMessage(userMessage)
}
.navigationTitle("Chat")
.navigationBarTitleDisplayMode(.inline)
}
}
func processMessage(_ message: any Message) async {
// Append the user's message to the messages array
if let defaultMessage = message as? DefaultMessage {
messages.append(defaultMessage)
}
// Simulate async response
try? await Task.sleep(for: .seconds(1))
await MainActor.run {
messages.append(DefaultMessage(
content: "You said: \(message.content ?? "")",
participant: .other
))
}
}
}@State private var messages: [DefaultMessage] = [
.init(content: "Hello! How can I help you today?", participant: .other),
.init(content: "I'm doing great, thanks!", participant: .user),
.init(content: "That's wonderful to hear!", participant: .other)
]The basic unit of conversation is the Message protocol. You can use your own types to represent messages, as long as they conform to this protocol.
public protocol Message: Identifiable, Hashable {
var content: String? { get set }
var imageURL: String? { get }
var participant: Participant { get }
var error: (any Error)? { get }
init(content: String?, imageURL: String?, participant: Participant)
}
public enum Participant {
case other
case user
}ConversationKit provides a default implementation of this protocol, DefaultMessage.
The main chat interface. It can be initialized in a few ways:
- With
DefaultMessageand attachments:ConversationView( messages: $messages, attachments: $attachments, userPrompt: $userPrompt )
- With a custom message type and attachments:
ConversationView<MyCustomMessage>( messages: $messages, attachments: $attachments, userPrompt: $userPrompt )
- With custom message rendering:
ConversationView( messages: $messages, attachments: $attachments, userPrompt: $userPrompt ) { message in // Your custom message view CustomMessageView(message: message) }
When the user sends a message, ConversationView automatically tracks the execution of your onSendMessage block. During this time, the "Send" button is replaced by a "Stop" button.
To make the stop button work properly, your async code must be aware of Swift's cooperative cancellation. You can do this by using APIs that automatically throw CancellationError (like URLSession), or by checking manually during streaming operations:
.onSendMessage { message in
messages.append(message)
// Example: A custom async stream
let stream = await myAIService.streamResponse(message)
for try await chunk in stream {
// This line is required for the "Stop" button to cancel the stream
try Task.checkCancellation()
messages.last?.content?.append(chunk)
}
}For complete control over message appearance:
ConversationView(messages: $messages) { message in
VStack {
// Handle images
if let imageURL = message.imageURL {
AsyncImage(url: URL(string: imageURL)) { phase in
if let image = phase.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: 200, maxHeight: 400)
} else if phase.error != nil {
Image(systemName: "icloud.slash")
} else {
ProgressView()
}
}
.cornerRadius(8.0)
}
// Handle text content
if let content = message.content {
HStack {
if message.participant == .user {
Spacer()
}
Markdown(content)
.padding()
.background {
Color(uiColor: message.participant == .other
? .secondarySystemBackground
: .systemGray4)
}
.roundedCorner(10, corners: .allCorners)
if message.participant == .other {
Spacer()
}
}
}
}
}ConversationKit features a custom, physics-driven scrolling paradigm designed specifically for AI chatbots. When a user sends a short message, it rests naturally at the bottom above the composer. As the AI begins generating its response directly below, the expanding text smoothly pushes the user's message upward natively. The exact moment the user's message touches the top navigation bar, the ScrollView natively pins it in place, allowing the rest of the massive generated response to flow organically downwards out of view!
This relies entirely on SwiftUI's brilliant internal layout clamping, removing the need for fragile GeometryReader clutches, and ensures the user is never violently auto-scrolled away from the text they are reading.
Important Note: To deliver this butter-smooth scroll physics, ConversationKit utilizes an Optimistic UI state. It instantly adds the user's message to the layout internally the exact millisecond the Send button is tapped to perfectly sync with the keyboard dismissal animation. You still must append the user's message to your own messages array inside your onSendMessage closure (as documented below). The SDK automatically deduplicates your actual message against its optimistic placeholder.
Critical UUID Constraint: When mapping the user's message into your array, you must preserve the exact
message.idprovided to you in the.onSendMessageclosure. If you create a new Message with a brand new UUID instead, the deduplication engine will fail and the message will momentarily jump or appear twice.
Support for real-time streaming responses is fully native:
func streamResponse() async {
let responseText = "This is a streaming response that appears character by character."
var message = DefaultMessage(content: "", participant: .other)
messages.append(message)
for character in responseText {
message.content?.append(character)
messages[messages.count - 1] = message
try? await Task.sleep(for: .milliseconds(100))
}
}Add custom attachment functionality:
ConversationView(messages: $messages)
.attachmentActions {
Button(action: { /* handle photo selection */ }) {
Label("Photos", systemImage: "photo.on.rectangle.angled")
}
Button(action: { /* handle camera */ }) {
Label("Camera", systemImage: "camera")
}
Button(action: { /* handle documents */ }) {
Label("Documents", systemImage: "doc")
}
}You can disable the attachments button by applying the .disableAttachments() modifier to the ConversationView. This will hide the button in the MessageComposerView.
ConversationView(messages: $messages)
.disableAttachments()import ConversationKit
import FirebaseAI
@Observable
class FirebaseAIChatViewModel {
var messages: [DefaultMessage] = []
private let model: GenerativeModel
private let chat: Chat
init() {
let firstMessage = DefaultMessage(
content: "Hello! How can I help you today?",
participant: .other
)
self.messages = [firstMessage]
model = FirebaseAI
.firebaseAI(backend: .googleAI())
.generativeModel(modelName: "gemini-2.5-flash")
let history = [
ModelContent(role: "model", parts: firstMessage.content ?? "")
]
chat = model.startChat(history: history)
}
func sendMessage(_ message: any Message) async {
if let defaultMessage = message as? DefaultMessage {
messages.append(defaultMessage)
}
if let content = message.content {
var responseText: String
do {
let response = try await chat.sendMessage(content)
responseText = response.text ?? ""
} catch {
responseText = "I'm sorry, I don't understand that. Please try again. \(error.localizedDescription)"
}
let response = DefaultMessage(content: responseText, participant: .other)
messages.append(response)
}
}
}
struct FirebaseAIChatView: View {
@State private var viewModel = FirebaseAIChatViewModel()
var body: some View {
NavigationStack {
ConversationView(messages: $viewModel.messages)
.navigationTitle("AI Chat")
.navigationBarTitleDisplayMode(.inline)
.onSendMessage { message in
await viewModel.sendMessage(message)
}
}
}
}import ConversationKit
import FoundationModels
struct FoundationModelChatView: View {
@State private var messages: [DefaultMessage] = [
.init(content: "Hello! How can I help you today?", participant: .other)
]
let session = LanguageModelSession()
var body: some View {
NavigationStack {
ConversationView(messages: $messages)
.navigationTitle("AI Chat")
.navigationBarTitleDisplayMode(.inline)
.onSendMessage { message in
if let defaultMessage = message as? DefaultMessage {
messages.append(defaultMessage)
}
if let content = message.content {
var responseText: String
do {
let response = try await session.respond(to: content)
responseText = response.content
} catch {
responseText = "I'm sorry, I don't understand that. Please try again. \(error.localizedDescription)"
}
let response = DefaultMessage(content: responseText, participant: .other)
messages.append(response)
}
}
}
}
}ConversationKit provides a robust mechanism for handling and displaying errors that may occur during asynchronous operations, such as fetching a response from an AI service.
The Message protocol includes an optional error property. You can create a message with an associated error and display it in the conversation history. MessageView will automatically render a default error UI if a message contains an error.
.onSendMessage { userMessage in
do {
let response = try await chatService.sendMessage(userMessage.content ?? "")
await MainActor.run {
messages.append(DefaultMessage(content: response, participant: .other))
}
} catch {
await MainActor.run {
messages.append(DefaultMessage(
content: "Sorry, an error occurred.",
participant: .other,
error: error
))
}
}
}To handle errors presented by ConversationKit views (for example, when a user taps the info button on a message with an error), use the .onError(perform:) view modifier. This modifier allows you to catch the error and present it using any standard SwiftUI presentation mechanism.
For convenience when using presentation modifiers like .sheet(item:), ConversationKit provides an ErrorWrapper struct that makes any Error identifiable.
struct MyChatView: View {
@State private var messages: [DefaultMessage] = []
@State private var errorWrapper: ErrorWrapper?
var body: some View {
ConversationView(messages: $messages)
.onSendMessage { message in
// ... async logic that might throw an error
}
.onError { error in
errorWrapper = ErrorWrapper(error: error)
}
.sheet(item: $errorWrapper) { wrapper in
NavigationStack {
VStack {
Text("An Error Occurred")
.font(.headline)
.padding()
Text(wrapper.error.localizedDescription)
Spacer()
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Dismiss") {
errorWrapper = nil
}
.labelStyle(.titleOnly)
}
}
}
}
}
}DefaultMessage(content: "Hello, how are you?", participant: .user)DefaultMessage(
content: "Check out this image!",
imageURL: "https://example.com/image.jpg",
participant: .other
)DefaultMessage(
imageURL: "https://example.com/image.jpg",
participant: .user
)ConversationKit provides several environment values for customization:
onSendMessageAction: Async closure for handling sent messagesonSubmitAction: Closure for handling message submissiondisableAttachments: Boolean to disable attachment functionalityattachmentActions: Custom attachment menu actionspresentErrorAction: A closure to present an error to the user.
ConversationKit is licensed under the Apache License, Version 2.0. See the LICENSE file for more details.
Contributions are welcome! Please feel free to submit a Pull Request.