Skip to content

Instantly share code, notes, and snippets.

@hanrw
Created May 23, 2025 14:05
Show Gist options
  • Save hanrw/dd1acac47ebbc9a7a2f686d1335ead78 to your computer and use it in GitHub Desktop.
Save hanrw/dd1acac47ebbc9a7a2f686d1335ead78 to your computer and use it in GitHub Desktop.
ThinkingIndicatorView
import SwiftUI
struct ThinkingIndicatorView: View {
@Binding var isExpanded: Bool
let streamingText: String
let thinkingDuration: TimeInterval
@State private var animationOffset: CGFloat = 0
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Thinking bubble header
HStack(spacing: 12) {
// AI avatar
Circle()
.fill(
LinearGradient(
colors: [.blue, .purple],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 32, height: 32)
.overlay(
Image(systemName: "brain.head.profile")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white)
)
VStack(alignment: .leading, spacing: 2) {
HStack {
Text("Thought for \(Int(thinkingDuration)) second\(thinkingDuration >= 2 ? "s" : "")")
.font(.callout.weight(.medium))
.foregroundStyle(.blue)
// Animated thinking dots
HStack(spacing: 2) {
ForEach(0..<3, id: \.self) { index in
Circle()
.fill(.blue.opacity(0.6))
.frame(width: 4, height: 4)
.scaleEffect(1 + sin(animationOffset + Double(index) * 0.5) * 0.5)
}
}
.onAppear {
withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) {
animationOffset = .pi * 2
}
}
}
Button(action: {
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
isExpanded.toggle()
}
}) {
HStack(spacing: 4) {
Text("Tap to read my mind")
.font(.caption)
.foregroundStyle(.secondary)
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
}
Spacer()
}
// Expandable content area
if isExpanded {
VStack(alignment: .leading, spacing: 8) {
HStack {
Rectangle()
.fill(.blue.opacity(0.3))
.frame(width: 3)
VStack(alignment: .leading, spacing: 8) {
Text("Thinking Process")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
if !streamingText.isEmpty {
StreamingTextView(text: streamingText)
} else {
Text("Analyzing your request and formulating a response...")
.font(.caption)
.foregroundStyle(.secondary)
.italic()
}
}
Spacer()
}
}
.padding(.vertical, 12)
.padding(.horizontal, 16)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(.blue.opacity(0.05))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(.blue.opacity(0.2), lineWidth: 1)
)
)
.transition(.asymmetric(
insertion: .scale(scale: 0.95).combined(with: .opacity),
removal: .scale(scale: 0.95).combined(with: .opacity)
))
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(.blue.opacity(0.3), lineWidth: 1.5)
)
)
.shadow(color: .blue.opacity(0.1), radius: 8, x: 0, y: 4)
}
}
// MARK: - Streaming Text View
struct StreamingTextView: View {
let text: String
@State private var displayedText = ""
@State private var showCursor = true
var body: some View {
HStack(alignment: .top) {
Text(displayedText)
.font(.caption)
.foregroundStyle(.primary)
.textSelection(.enabled)
// Typing cursor
if showCursor {
Rectangle()
.fill(.blue)
.frame(width: 2, height: 12)
.opacity(showCursor ? 1 : 0)
.animation(.easeInOut(duration: 0.5).repeatForever(autoreverses: true), value: showCursor)
}
Spacer()
}
.onChange(of: text) { newText in
displayedText = newText
}
.onAppear {
startCursorAnimation()
}
}
private func startCursorAnimation() {
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
showCursor.toggle()
}
}
}
#Preview {
ThinkingIndicatorView(
isExpanded: .constant(true),
streamingText: "This is a sample streaming text that would appear as the AI thinks through the problem...",
thinkingDuration: 3.5
)
.padding()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment