Skip to content

Commit 7c02ed8

Browse files
authored
feat: Gemini-style 'Sticky Top' Scrolling & Progressive Disclosure UX
1 parent 6eda6d2 commit 7c02ed8

8 files changed

Lines changed: 480 additions & 349 deletions

File tree

ARCHITECTURE.md

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# ConversationKit: ConversationView Rewrite PRD & Architecture
2+
3+
## 1. Introduction
4+
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.
5+
6+
## 2. Product Requirements Document (PRD)
7+
8+
### 2.1 Goals
9+
* **Gemini-like UX:** Implement a smooth, streaming-friendly chat interface with specific scrolling behaviors ("Sticky Top").
10+
* **Distinct Styling:** Provide strong default visual styles differentiating user inputs from AI responses, matching modern conversational UI standards.
11+
* **Progressive Disclosure:** Expose flexible, SwiftUI-canonical extension points for developers to inject custom views (actions, disclaimers) without cluttering the base API.
12+
* **Zero API Breakage:** The core `ConversationView` initializers and environment modifiers (like `.onSendMessage`) must remain unchanged.
13+
14+
### 2.2 User Experience (UX) & Interactions
15+
16+
**2.2.1 Visual Layout & Styling**
17+
* **User Messages:**
18+
* Right-aligned.
19+
* Contained within a background bubble.
20+
* **Shape:** Rounded corners on all sides, with one corner (typically bottom-right) being *slightly less rounded* (a "tail" effect) to indicate the speaker.
21+
* **AI (Other) Messages:**
22+
* Left-aligned.
23+
* No background bubble.
24+
* Utilizes full markdown rendering natively.
25+
* **Custom Styling:** If a developer provides a custom `content` ViewBuilder closure to `ConversationView`, the default styling is bypassed entirely.
26+
* **Loading State:** If an AI message (`participant == .other`) is added to the model with `content == nil` (or empty), the UI must render a loading indicator (e.g., ProgressView/sparkle) at that message's location.
27+
28+
**2.2.2 Scrolling Behavior (The "Push and Pin" Paradigm)**
29+
* **Send Action:**
30+
1. Keyboard immediately dismisses.
31+
2. `ScrollView` targets the *newly sent user message* with an `anchor: .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.
32+
* **Streaming Content:** As the AI response streams in, it expands downward. The growing content naturally *pushes* the user's anchored message upwards.
33+
* **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: .top` constraint. 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.
34+
* **Manual Override:** Any manual scrolling gesture by the user instantly breaks the top anchor, allowing free, user-directed scrolling.
35+
* **Subsequent Turns:** The next user message resets the behavior, anchoring the new message with `.top`.
36+
37+
**2.2.3 Auxiliary Views (Progressive Disclosure)**
38+
* **Message Actions:** AI messages must support an optional action bar beneath the text (e.g., Thumbs Up/Down, Regenerate, Copy).
39+
* **Disclaimer Text:** A customizable disclaimer must appear **only** beneath the most recent AI message in the thread.
40+
41+
**2.2.4 Message Composer Experience**
42+
* **Empty State:** The "Send" button is visually disabled if the message input is empty. It relies strictly on native platform disabled styling (e.g., `.borderedProminent` on iOS) to ensure high-contrast readability without artificial opacity stacking.
43+
* **Generation State (Send/Stop):** When a user sends a message, the `ConversationView` must track the execution of the `onSendMessageAction` closure. 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.
44+
* **Cancellation:** Tapping the Stop button calls `cancel()` on the executing `Task`. 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 checks `Task.isCancelled` to make the stop button effective.
45+
46+
---
47+
48+
## 3. Architecture & Design Document
49+
50+
### 3.1 Core Architecture Changes
51+
52+
The internal `body` of `ConversationView` will be restructured to support precise programmatic scrolling and state tracking.
53+
54+
**Components:**
55+
1. **`ScrollView` + `ScrollViewReader`:** Essential for programmatic anchoring.
56+
2. **Scroll State Tracking:** We must track whether the user is actively dragging. This prevents the view from snapping back to an anchor when the `messages` array updates during an active manual scroll.
57+
* *Implementation detail:* Depending on minimum iOS version targets, this involves `onScrollPhaseChange` (iOS 18+) or preference keys/geometry readers tracking scroll offset changes.
58+
3. **`MessageView` (Default Renderer):** Will be updated to handle the `UnevenRoundedRectangle` shape for `.user` and the loading indicator logic for `.other` when `content` is nil.
59+
60+
### 3.2 State & Data Flow (No API Breakage Strategy)
61+
62+
**The "Push and Pin" Anchor Strategy for Scroll Physics:**
63+
* 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.
64+
* 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.
65+
* **Resolution:** `ConversationView` maintains a local `@State private var optimisticUserMessage`. When the user taps send, this local state is populated instantly. It tells the `ScrollView` to target this message with `anchor: .top`.
66+
* **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 `.top` anchor constraint and locks it in place!
67+
* The actual developer array catches up asynchronously a millisecond later via `onSendMessageAction`, and `displayedMessages` deduplicates it seamlessly.
68+
69+
**Loading Indicator:**
70+
* The SDK relies on the developer to immediately append a placeholder `Message` (where `participant == .other` and `content == nil`) before starting the async network request.
71+
* `ConversationView` simply reacts to this state natively to render the loading dots.
72+
73+
**Send/Stop Button & Task Cancellation:**
74+
* `ConversationView` maintains a `@State private var sendingTask: Task<Void, Never>?`.
75+
* When a message is sent, the task is captured: `sendingTask = Task { await onSendMessageAction(...) }`.
76+
* An Environment value (`isGenerating`) is passed down to `MessageComposerView` based on whether `sendingTask` is non-nil.
77+
* The Stop button triggers `sendingTask?.cancel()`. Because cancellation is cooperative, the `onSendMessage` block must properly utilize `try Task.checkCancellation()` to halt the stream.
78+
79+
### 3.3 Progressive Disclosure API Design
80+
81+
We will introduce new SwiftUI `EnvironmentValues` and corresponding View Modifiers to enable the required customizability.
82+
83+
**3.3.1 Message Actions**
84+
```swift
85+
extension EnvironmentValues {
86+
@Entry var messageActions: ((any Message) -> AnyView)?
87+
}
88+
89+
public extension View {
90+
func messageActions<V: View>(@ViewBuilder actions: @escaping (any Message) -> V) -> some View {
91+
environment(\.messageActions, { message in AnyView(actions(message)) })
92+
}
93+
}
94+
```
95+
*Usage within ConversationView:* Evaluated inside the loop over `messages`, beneath the message content, specifically when `participant == .other`.
96+
97+
**3.3.2 Disclaimer View**
98+
```swift
99+
extension EnvironmentValues {
100+
@Entry var conversationDisclaimer: AnyView?
101+
}
102+
103+
public extension View {
104+
func conversationDisclaimer<V: View>(@ViewBuilder disclaimer: @escaping () -> V) -> some View {
105+
environment(\.conversationDisclaimer, AnyView(disclaimer()))
106+
}
107+
}
108+
```
109+
*Usage within ConversationView:* Rendered at the bottom of the `LazyVStack` only if `messages.last?.participant == .other`.
110+
111+
## 4. Implementation Phasing
112+
1. **Foundation:** Implement new Environment Keys and Modifiers for Actions and Disclaimer.
113+
2. **Styling:** Update `MessageView` to implement the `UnevenRoundedRectangle` user bubble and the `nil` content loading state.
114+
3. **Scroll Architecture:** Refactor `ConversationView` to use `ScrollViewReader` and implement the scroll tracking logic.
115+
4. **Behavior:** Implement the "Push and Pin" anchor logic triggered upon message submission, respecting manual scroll overrides.
116+
5. **Integration:** Assemble the Action bars and Disclaimer into the updated scroll view layout.

Examples/ConversationKitDemo/ConversationKitDemo/ContentView.swift

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,12 @@ struct ContentView: View {
5555
withAnimation {
5656
self.attachments.removeAll()
5757
}
58-
Task {
59-
print("You said: \(userMessage.content ?? "nothing")")
58+
59+
let content = userMessage.content ?? ""
60+
print("You said: \(content)")
61+
if content.localizedCaseInsensitiveContains("long") {
62+
await generateLongResponse()
63+
} else {
6064
await generateResponse(for: userMessage)
6165
}
6266
}
@@ -96,6 +100,7 @@ struct ContentView: View {
96100
}
97101
}
98102

103+
@MainActor
99104
func generateResponse(for message: any Message) async {
100105
let text =
101106
"Culpa *amet* irure aliquip qui deserunt ullamco tempor do irure anim amet do incididunt. Tempor et dolor qui. Aliqua **anim** aliqua elit in. Veniam veniam magna aliquip. Anim eu et excepteur voluptate labore reprehenderit exercitation voluptate fugiat dolor reprehenderit tempor esse et amet."
@@ -122,12 +127,63 @@ struct ContentView: View {
122127

123128
let randomDelay = Double.random(in: 0.2...0.4) // Adjust delay for chunks
124129
try await Task.sleep(nanoseconds: UInt64(randomDelay * 100_000_000))
130+
131+
try Task.checkCancellation()
125132
}
126133
} catch {
127134
// Handle errors if needed
128135
}
129136
}
130137

138+
@MainActor
139+
func generateLongResponse() async {
140+
let paragraphs = [
141+
"Here is a very long story just for you.",
142+
"Once upon a time in a digital realm far, far away, there lived a small bit of data named Byte. Byte was curious and always wanted to travel across the vast networks of the internet.",
143+
"One day, Byte found a packet header that was perfectly sized and hopped aboard. The journey was perilous, traversing through numerous routers, switches, and firewalls.",
144+
"At one point, Byte encountered a massive traffic jam at a transatlantic cable node. Packets were dropping left and right. It was a chaotic scene, but Byte managed to reroute through a satellite link.",
145+
"Floating through space, Byte saw the earth below—a beautiful blue marble interconnected by invisible threads of communication.",
146+
"Eventually, Byte landed safely in a cozy little server rack in Iceland, surrounded by humming cooling fans and the gentle blinking of LED lights.",
147+
"But the adventure didn't stop there. Byte was soon requested by a client application on a mobile device.",
148+
"Zipping back through fiber optic cables at the speed of light, Byte finally arrived on the screen of a user, bringing a tiny pixel to life to form part of a beautiful image.",
149+
"And so, Byte's journey came to an end, having successfully delivered the payload. It was a fulfilling existence, being part of the grand tapestry of human connection and information exchange.",
150+
"The end. I hope you enjoyed this incredibly long and detailed story about a single byte of data traversing the global internet infrastructure."
151+
]
152+
153+
var generatedText = ""
154+
var message = DefaultMessage(content: nil, participant: .other) // Start with loading state
155+
messages.append(message)
156+
157+
// Simulate loading delay
158+
try? await Task.sleep(for: .seconds(1))
159+
160+
let chunkSize = 5
161+
162+
for paragraph in paragraphs {
163+
let textToStream = (generatedText.isEmpty ? "" : "\n\n") + paragraph
164+
for chunkStart in stride(from: 0, to: textToStream.count, by: chunkSize) {
165+
let chunkEnd = min(chunkStart + chunkSize, textToStream.count)
166+
let chunk = textToStream[
167+
textToStream.index(textToStream.startIndex, offsetBy: chunkStart)..<textToStream.index(textToStream.startIndex, offsetBy: chunkEnd)
168+
]
169+
170+
generatedText.append(String(chunk))
171+
message.content = generatedText
172+
messages[messages.count - 1] = message
173+
174+
let randomDelay = Double.random(in: 0.05...0.1)
175+
try? await Task.sleep(for: .seconds(randomDelay))
176+
177+
// This is strictly required for the new "Stop" button feature to work cooperatively
178+
do {
179+
try Task.checkCancellation()
180+
} catch {
181+
return // Stop streaming if cancelled
182+
}
183+
}
184+
}
185+
}
186+
131187
}
132188

133189
#Preview {

GEMINI.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,25 @@ It doesn't have any internal dependencies on other components within this projec
2727

2828
* **Building and Running**: To use this component, add it as a package dependency to your Xcode project.
2929
* **Testing**: The project has a dedicated test target, `ConversationKitTests`, for unit tests.
30-
* **Architectural Patterns**: The component follows standard Swift and SwiftUI conventions. It uses a protocol-based approach for the message data model and makes extensive use of SwiftUI's environment values and view modifiers for customization.
30+
* **Architectural Patterns**: The component follows standard Swift and SwiftUI conventions. It uses a protocol-based approach for the message data model and makes extensive use of SwiftUI's environment values and view modifiers for customization, adopting a Progressive Disclosure API design for advanced styling like `.messageActions` and `.conversationDisclaimer`.
31+
32+
## SwiftUI Scroll Physics & Concurrency
33+
34+
`ConversationKit` implements a highly specialized, native scrolling UX matching modern conversational AI interfaces. When the user sends a message, it doesn't violently snap to the top. Instead, it rests comfortably above the text input field. As the AI begins generating its response directly below, the new text smoothly *pushes* the user's message upward until it hits the top navigation bar, at which point it securely *pins* in place, allowing the rest of the generated response to flow downwards off the screen.
35+
36+
To achieve this "Push and Pin" behavior entirely within SwiftUI's native declarative layout engine (without fragile `GeometryReader` clutches), we explicitly tell `.scrollPosition` to target the user's message with `anchor: .top`.
37+
**The "Happy Accident":** By default, SwiftUI physically refuses to push short content lists all the way to the top of a ScrollView. So, the `.top` anchor gracefully fails, leaving the short message at the bottom. As the AI response adds tokens and the content height finally exceeds the screen frame, SwiftUI is finally able to satisfy the `.top` anchor constraint, natively pinning the user's message exactly where it should be!
38+
39+
**Concurrency Optimization (Optimistic UI)**
40+
To make this work fluidly, the layout engine must process the new user message in the exact same render transaction as the keyboard dismissal. Because the SDK intentionally *does not own* the messages array, relying on developers to asynchronously append their messages inside the `async` `onSendMessage` closure caused a 1-frame layout micro-delay that completely broke the `.top` physics.
41+
42+
The SDK resolves this conflict via an **Optimistic UI anchor strategy**:
43+
1. `ConversationView` instantly captures the sent message into a local `@State` variable (`optimisticUserMessage`).
44+
2. A computed property (`displayedMessages`) merges this local message with the developer's array, forcing a synchronous layout update that sets up the `.top` target exactly as the keyboard vanishes.
45+
3. A background `@MainActor` `Task` is spawned, yielding execution to allow the layout engine to render the scroll animation, preventing UI deadlocks while the developer performs their async array updates or network calls.
46+
4. As the developer appends the actual message, `displayedMessages` natively deduplicates it against the optimistic copy, resulting in flawless scroll physics while strictly maintaining the "SDK does not own the array" architectural rule.
47+
48+
> **Important API Contract Note:** The deduplication logic in step 4 relies explicitly on the `id` of the user's message. When the developer's `.onSendMessage` closure executes, they *must* append the exact `message` instance provided by the closure, or map it into a new model using the identical `message.id`. If they map the text into a completely new object with a randomly generated UUID, the deduplication engine will fail to recognize them as the same message, causing the message to briefly appear twice on screen before the optimistic placeholder expires.
3149
3250
## Usage and Integration
3351

@@ -76,3 +94,5 @@ struct ChatView: View {
7694
* The `ConversationView` does not own the `messages` array. The parent view is responsible for creating and managing the array.
7795
* The library uses `async/await` for handling message sending and processing.
7896
* The `Message` protocol includes an optional `error` property, allowing errors to be attached to messages and displayed in the UI.
97+
* **Loading Indicators:** To display a loading state, simply append an AI (`.other`) message with a `nil` or empty `content`. `ConversationView` natively renders a loading view for this state without API breakage.
98+
* **Generation State (Send/Stop):** The composer's "Send" button will be disabled if the text field is empty. When `onSendMessage` executes, the "Send" button transforms into a "Stop" button. Tapping it calls `Task.cancel()` on the executing task. End-developers must rely on Swift's cooperative cancellation by checking `try Task.checkCancellation()` within any streaming loops to make the stop button functional.

0 commit comments

Comments
 (0)