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() }