This document outlines the Product Requirements and Technical Architecture for rewriting ConversationKit's ConversationView. The objective is to closely mimic the scrolling, layout, and interaction paradigms of the Google Gemini iOS application while strictly preserving the existing public API.
- Gemini-like UX: Implement a smooth, streaming-friendly chat interface with specific scrolling behaviors ("Sticky Top").
- Distinct Styling: Provide strong default visual styles differentiating user inputs from AI responses, matching modern conversational UI standards.
- Progressive Disclosure: Expose flexible, SwiftUI-canonical extension points for developers to inject custom views (actions, disclaimers) without cluttering the base API.
- Zero API Breakage: The core
ConversationViewinitializers and environment modifiers (like.onSendMessage) must remain unchanged.
2.2.1 Visual Layout & Styling
- User Messages:
- Right-aligned.
- Contained within a background bubble.
- Shape: Rounded corners on all sides, with one corner (typically bottom-right) being slightly less rounded (a "tail" effect) to indicate the speaker.
- AI (Other) Messages:
- Left-aligned.
- No background bubble.
- Utilizes full markdown rendering natively.
- Custom Styling: If a developer provides a custom
contentViewBuilder closure toConversationView, the default styling is bypassed entirely. - Loading State: If an AI message (
participant == .other) is added to the model withcontent == nil(or empty), the UI must render a loading indicator (e.g., ProgressView/sparkle) at that message's location.
2.2.2 Scrolling Behavior (The "Push and Pin" Paradigm)
- Send Action:
- Keyboard immediately dismisses.
ScrollViewtargets the newly sent user message with ananchor: .top. However, due to native SwiftUI content-clamping, if the chat is short, the message rests naturally at the bottom above the composer rather than artificially snapping to the absolute top of the screen.
- Streaming Content: As the AI response streams in, it expands downward. The growing content naturally pushes the user's anchored message upwards.
- The Pin: The exact moment the total height of the content exceeds the screen's height, SwiftUI's layout engine can finally satisfy the
anchor: .topconstraint. The user's message seamlessly pins to the top navigation bar, and the remaining AI response continues to grow out the bottom out of view. - Manual Override: Any manual scrolling gesture by the user instantly breaks the top anchor, allowing free, user-directed scrolling.
- Subsequent Turns: The next user message resets the behavior, anchoring the new message with
.top.
2.2.3 Auxiliary Views (Progressive Disclosure)
- Message Actions: AI messages must support an optional action bar beneath the text (e.g., Thumbs Up/Down, Regenerate, Copy).
- Disclaimer Text: A customizable disclaimer must appear only beneath the most recent AI message in the thread.
2.2.4 Message Composer Experience
- Empty State: The "Send" button is visually disabled if the message input is empty. It relies strictly on native platform disabled styling (e.g.,
.borderedProminenton iOS) to ensure high-contrast readability without artificial opacity stacking. - Generation State (Send/Stop): When a user sends a message, the
ConversationViewmust track the execution of theonSendMessageActionclosure. While this closure is executing, the composer's "Send" button must transform into a "Stop" button (stop.fill). The stop icon utilizes specific, reduced font sizing to maintain visual mass balance against the default send arrow. - Cancellation: Tapping the Stop button calls
cancel()on the executingTask. This implements a "Zero API Breakage" stop mechanism by relying entirely on standard Swift Cooperative Cancellation. Developers using the SDK must ensure their networking/streaming code checksTask.isCancelledto make the stop button effective.
The internal body of ConversationView will be restructured to support precise programmatic scrolling and state tracking.
Components:
ScrollView+ScrollViewReader: Essential for programmatic anchoring.- Scroll State Tracking: We must track whether the user is actively dragging. This prevents the view from snapping back to an anchor when the
messagesarray updates during an active manual scroll.- Implementation detail: Depending on minimum iOS version targets, this involves
onScrollPhaseChange(iOS 18+) or preference keys/geometry readers tracking scroll offset changes.
- Implementation detail: Depending on minimum iOS version targets, this involves
MessageView(Default Renderer): Will be updated to handle theUnevenRoundedRectangleshape for.userand the loading indicator logic for.otherwhencontentis nil.
The "Push and Pin" Anchor Strategy for Scroll Physics:
- To achieve perfect conversational AI scrolling physics when a user taps Send, the layout engine must invalidate in the exact same transaction as the keyboard dismissal.
- The goal is to let the user's message appear near the bottom, be pushed up by the AI's generated response, and then permanently pin to the top of the screen once the response grows too long.
- Resolution:
ConversationViewmaintains a local@State private var optimisticUserMessage. When the user taps send, this local state is populated instantly. It tells theScrollViewto target this message withanchor: .top. - The "Happy Accident": SwiftUI's layout clamping physically refuses to push short content to the top. So, the message naturally rests at the bottom. As the AI streams text, the total height grows, physically pushing the anchored message upwards until it hits the top edge, at which point SwiftUI finally satisfies the
.topanchor constraint and locks it in place! - The actual developer array catches up asynchronously a millisecond later via
onSendMessageAction, anddisplayedMessagesdeduplicates it seamlessly.
Loading Indicator:
- The SDK relies on the developer to immediately append a placeholder
Message(whereparticipant == .otherandcontent == nil) before starting the async network request. ConversationViewsimply reacts to this state natively to render the loading dots.
Send/Stop Button & Task Cancellation:
ConversationViewmaintains a@State private var sendingTask: Task<Void, Never>?.- When a message is sent, the task is captured:
sendingTask = Task { await onSendMessageAction(...) }. - An Environment value (
isGenerating) is passed down toMessageComposerViewbased on whethersendingTaskis non-nil. - The Stop button triggers
sendingTask?.cancel(). Because cancellation is cooperative, theonSendMessageblock must properly utilizetry Task.checkCancellation()to halt the stream.
We will introduce new SwiftUI EnvironmentValues and corresponding View Modifiers to enable the required customizability.
3.3.1 Message Actions
extension EnvironmentValues {
@Entry var messageActions: ((any Message) -> AnyView)?
}
public extension View {
func messageActions<V: View>(@ViewBuilder actions: @escaping (any Message) -> V) -> some View {
environment(\.messageActions, { message in AnyView(actions(message)) })
}
}Usage within ConversationView: Evaluated inside the loop over messages, beneath the message content, specifically when participant == .other.
3.3.2 Disclaimer View
extension EnvironmentValues {
@Entry var conversationDisclaimer: AnyView?
}
public extension View {
func conversationDisclaimer<V: View>(@ViewBuilder disclaimer: @escaping () -> V) -> some View {
environment(\.conversationDisclaimer, AnyView(disclaimer()))
}
}Usage within ConversationView: Rendered at the bottom of the LazyVStack only if messages.last?.participant == .other.
- Foundation: Implement new Environment Keys and Modifiers for Actions and Disclaimer.
- Styling: Update
MessageViewto implement theUnevenRoundedRectangleuser bubble and thenilcontent loading state. - Scroll Architecture: Refactor
ConversationViewto useScrollViewReaderand implement the scroll tracking logic. - Behavior: Implement the "Push and Pin" anchor logic triggered upon message submission, respecting manual scroll overrides.
- Integration: Assemble the Action bars and Disclaimer into the updated scroll view layout.