AI Chat Interface with Voice Input
Complete chat module with message list, streaming-ready input bar, voice-to-text via VolcEngine ASR, and AI conversation UI patterns (iOS only)
Copies a short instruction with your private download link. Paste it into your agent: it downloads the whole skill (the source code, the setup steps and the mistakes we already made for you) and builds the screen.
Works with
What it does
Provides a production-ready AI chat interface with an auto-scrolling message list (no CPU spikes during streaming), a text input bar with optional voice-to-text via VolcEngine ASR, and composable bubble styling — ready to connect to any AI backend.
Permission timing strategy: the ASR module uses the use-site request pattern — microphone authorization is requested when startRecording() is called. For an overview of all three permission patterns (use-site / onboarding prefetch / deferred), see Permission Prefetch Pattern.What you get
- ✓1,805 lines of source code, ready to paste
- ✓Architecture
- ✓Dependencies
- ✓Implementation
- ✓Integration Checklist
- ✓Common Customizations
- ✓Known Pitfalls
Architecture
iOS App
SWChatView (all-in-one chat component)
|
|--- SWMessageList (auto-scrolling message list)
| |--- SWMessageBubble (alignment wrapper)
| |--- ScrollViewReader + throttleScroll (anchors scroll to bottom)
|
|--- SWChatInputView (text + voice input bar)
| |--- TextField (expandable, 1-5 lines)
| |--- Microphone button (hidden when asrConfig is nil)
| |--- SWAudioWaveformView (animated bars during recording)
| |--- Send button
| |
| |--- SWVolcEngineASRService (speech-to-text)
| |--- AVAudioEngine (microphone capture)
| |--- NWConnection (WebSocket to VolcEngine)
| |--- Binary protocol (gzip compressed)
| |--- Callbacks: onTranscriptionUpdate / onTranscriptionComplete / onError
|
|--- SWChatMessage (Identifiable message model)
|
v
Your AI Backend (OpenAI, Claude, custom, etc.)Data flow:
- User types or speaks a message
SWChatViewappends the user message to the binding automaticallyonSendcallback fires with the text — you forward it to your AI backend- You append the AI response to the same messages array
- The auto-scrolling list keeps the newest message visible with no layout issues
Integration Checklist
- Add microphone permission to
Info.plist:
<key>NSMicrophoneUsageDescription</key>
<string>Microphone access is needed for voice input</string>Required only if you use the voice input feature (asrConfig is non-nil).
- Obtain VolcEngine ASR credentials (voice input only):
- Sign up at VolcEngine Console
- Enable the Streaming ASR service
- Create an application to get
appIdandaccessToken - Choose the correct
clusterfor your region (default:volcengine_streaming_common)
The accessToken you pass to SWASRConfig ships inside your app. Don't hard-code a long-lived token in source control; prefer fetching it from your backend at runtime so you can rotate it.
- Add the files to your Xcode project:
SWChatView+iOS.swiftSWChatInputView+iOS.swiftSWMessageList+iOS.swiftSWVolcEngineASRService+iOS.swiftSWDebugLog.swift— skip it if your project already has ShipSwift'sSWUtilor the paywall-kit skill
If your app also builds for macOS, limit the four +iOS files to iOS in Build Phases > Compile Sources.
- Connect to your AI backend in the
onSendcallback:
SWChatView(messages: $messages) { text in
Task {
let response = await yourAIService.chat(text)
messages.append(SWChatMessage(content: response, isUser: false))
}
}- Set deployment target to iOS 18.0 or later.
Known Pitfalls
1. Microphone permission must be requested at runtime
iOS requires runtime permission for microphone access. SWVolcEngineASRService.startRecording() handles this automatically, but if the user denies permission, it throws SWASRError.microphonePermissionDenied. Always handle this error gracefully (e.g., show an alert directing the user to Settings).
2. Do NOT use ScrollView + LazyVStack for chat
ScrollView + LazyVStack causes 100% CPU usage during streaming updates due to infinite layout calculation loops. SWMessageList uses List with ScrollViewReader and throttled auto-scroll instead, providing smooth bottom-anchoring without coordinate system inversion. See the doc comments in SWMessageList+iOS.swift for details.
3. Audio session configuration on iOS
The ASR service sets the audio session to .playAndRecord with .defaultToSpeaker and .allowBluetoothA2DP, and deactivates it (with .notifyOthersOnDeactivation, so music from other apps resumes) when recording stops or is cancelled. If your app plays its own audio (e.g., media playback or text-to-speech), set the category you need again before playing:
// After stopRecording()
try AVAudioSession.sharedInstance().setCategory(.playback)4. VolcEngine WebSocket uses a custom binary protocol
The VolcEngine ASR API does not use standard WebSocket text frames. It uses a custom binary protocol with gzip compression. The SWVolcEngineASRService handles all binary encoding/decoding internally. If you need to debug, look at the buildFullClientRequest and handleServerResponse methods.
5. Throttled scroll timing
The auto-scroll uses a 400ms leading throttle with a 450ms trailing guarantee. During very fast streaming (many tokens per second), the scroll fires at most once per 400ms to prevent jank, then a final trailing scroll ensures the list ends at the correct position. These values work well for typical AI streaming speeds — adjust only if you have unusual update patterns.
6. SWChatMessage is immutable by design
SWChatMessage uses let properties for thread safety. For streaming responses, replace the entire message object in the array rather than mutating properties. See the "Support streaming AI responses" section above.
7. Keyboard dismissal behavior
SWMessageList uses .scrollDismissesKeyboard(.immediately) to dismiss the keyboard when scrolling. The send button also dismisses focus before calling onSend() to prevent the keyboard from popping back up after sending.
8. ASR service lifecycle
Each recording session creates a new SWVolcEngineASRService instance and a new WebSocket connection. The service is not designed to be reused across multiple recording sessions. This is by design -- each session needs a fresh connection with a unique request ID. SWChatInputView cancels the previous session before starting a new one, and cancels a running session when the input bar leaves the screen, so the microphone and the connection are always released. onTranscriptionComplete is called once when the stream ends, even if nothing was recognized (with an empty string), and the microphone is released if the connection drops while still recording; after cancelRecording() no further callbacks are delivered.
More in Main screens









