diff --git a/README.md b/README.md index ec2a60a..8fee7bb 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ These entries are mandatory for apps that utilize microphone and speech recognit ### Chat View -The [`ChatView`](https://swiftpackageindex.com/stanfordspezi/spezichat/documentation/spezichat/chatview) provides a basic reusable chat view which includes a message input field. The input can be either typed out via the iOS keyboard or provided as voice input and transcribed into written text. +The [`ChatView`](https://swiftpackageindex.com/stanfordspezi/spezichat/documentation/spezichat/chatview) provides a basic reusable chat view which includes a message input field. The input can be either typed out via the iOS keyboard or provided as voice input and transcribed into written text. It accepts an additional `messagePendingAnimation` parameter to control whether a chat bubble animation is shown for a message that is currently being composed. By default, `messagePendingAnimation` has a value of `nil` and does not show. In addition, the [`ChatView`](https://swiftpackageindex.com/stanfordspezi/spezichat/documentation/spezichat/chatview) provides functionality to export the visualized [`Chat`](https://swiftpackageindex.com/stanfordspezi/spezichat/0.1.1/documentation/spezichat/chat) as a PDF document, JSON representation, or textual UTF-8 file (see `ChatView/ChatExportFormat`) via a Share Sheet (or Activity View). ```swift @@ -77,6 +77,7 @@ struct ChatTestView: View { The [`MessagesView`](https://swiftpackageindex.com/stanfordspezi/spezichat/documentation/spezichat/messagesview) displays a `Chat` containing multiple `ChatEntity`s with different `ChatEntity/Role`s in a typical chat-like fashion. The `View` automatically scrolls down to the newest message that is added to the passed `Chat` SwiftUI `Binding`. +The `typingIndicator` parameter controls when a typing indicator is shown onscreen for incoming messages to `Chat`. ```swift struct MessagesViewTestView: View { diff --git a/Sources/SpeziChat/ChatView.swift b/Sources/SpeziChat/ChatView.swift index d9a1363..090516b 100644 --- a/Sources/SpeziChat/ChatView.swift +++ b/Sources/SpeziChat/ChatView.swift @@ -58,6 +58,7 @@ public struct ChatView: View { var disableInput: Bool let exportFormat: ChatExportFormat? let messagePlaceholder: String? + let messagePendingAnimation: MessagesView.TypingIndicatorDisplayMode? @State var messageInputHeight: CGFloat = 0 @State private var showShareSheet = false @@ -66,7 +67,7 @@ public struct ChatView: View { public var body: some View { ZStack { VStack { - MessagesView($chat, bottomPadding: $messageInputHeight) + MessagesView($chat, typingIndicator: messagePendingAnimation, bottomPadding: $messageInputHeight) .gesture( TapGesture().onEnded { UIApplication.shared.sendAction( @@ -123,16 +124,19 @@ public struct ChatView: View { /// - disableInput: Flag if the input view should be disabled. /// - exportFormat: If specified, enables the export of the ``Chat`` displayed in the ``ChatView`` via a share sheet in various formats defined in ``ChatView/ChatExportFormat``. /// - messagePlaceholder: Placeholder text that should be added in the input field. + /// - messagePendingAnimation: Parameter to control whether a chat bubble animation is shown. public init( _ chat: Binding, disableInput: Bool = false, exportFormat: ChatExportFormat? = nil, - messagePlaceholder: String? = nil + messagePlaceholder: String? = nil, + messagePendingAnimation: MessagesView.TypingIndicatorDisplayMode? = nil ) { self._chat = chat self.disableInput = disableInput self.exportFormat = exportFormat self.messagePlaceholder = messagePlaceholder + self.messagePendingAnimation = messagePendingAnimation } } diff --git a/Sources/SpeziChat/MessageInputViewHeightKey.swift b/Sources/SpeziChat/Helpers/MessageInputViewHeightKey.swift similarity index 100% rename from Sources/SpeziChat/MessageInputViewHeightKey.swift rename to Sources/SpeziChat/Helpers/MessageInputViewHeightKey.swift diff --git a/Sources/SpeziChat/Helpers/TypingIndicator.swift b/Sources/SpeziChat/Helpers/TypingIndicator.swift new file mode 100644 index 0000000..b6d5e1f --- /dev/null +++ b/Sources/SpeziChat/Helpers/TypingIndicator.swift @@ -0,0 +1,69 @@ +// +// This source file is part of the Stanford Spezi open source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + +/// Creates a typing indicator animation for pending messages. +/// The animation consists of three dots that fade in and out in a sequential, wave-like pattern. +/// It loops continuously as long as `isAnimating` is `true`. +/// +/// Usage: +/// ```swift +/// struct ChatView: View { +/// var body: some View { +/// VStack { +/// MessageView(ChatEntity(role: .user, content: "User Message!")) +/// TypingIndicator() +/// } +/// } +/// } +/// ``` +public struct TypingIndicator: View { + @State var isAnimating = false + + public var body: some View { + HStack { + HStack(spacing: 3) { + ForEach(0..<3) { index in + Circle() + .opacity(self.isAnimating ? 1 : 0) + .foregroundStyle(.tertiary) + .animation( + Animation + .easeInOut(duration: 0.6) + .repeatForever(autoreverses: true) + .delay(0.2 * Double(index)), + value: self.isAnimating + ) + .frame(width: 10) + } + .accessibilityIdentifier(String(localized: "TYPING_INDICATOR", bundle: .module)) + } + .frame(width: 42, height: 12, alignment: .center) + .padding(.vertical, 4) + .onAppear { + self.isAnimating = true + } + .chatMessageStyle(alignment: .leading) + Spacer(minLength: 32) + } + } +} + +#Preview { + ScrollView { + VStack { + MessageView(ChatEntity(role: .system, content: "System Message!"), hideMessagesWithRoles: []) + MessageView(ChatEntity(role: .system, content: "System Message (hidden)!")) + MessageView(ChatEntity(role: .function(name: "test_function"), content: "Function Message!"), hideMessagesWithRoles: [.system]) + MessageView(ChatEntity(role: .user, content: "User Message!")) + TypingIndicator() + } + .padding() + } +} diff --git a/Sources/SpeziChat/MessagesView.swift b/Sources/SpeziChat/MessagesView.swift index a867f28..a618436 100644 --- a/Sources/SpeziChat/MessagesView.swift +++ b/Sources/SpeziChat/MessagesView.swift @@ -30,11 +30,23 @@ import SwiftUI /// } /// ``` public struct MessagesView: View { + /// Represents a configuration used in the initializer of ``MessagesView`` to specify when to display an animation indicating a pending message from a chat participant. + /// + /// ``TypingIndicatorDisplayMode`` has two possible cases: + /// - ``TypingIndicatorDisplayMode/automatic``: The animation is shown whenever the last message in the chat is from the user, + /// and the assistant has not yet begun to respond. + /// - ``TypingIndicatorDisplayMode/manual(shouldDisplay:)``: The animation will be displayed based on the provided Boolean flag. + public enum TypingIndicatorDisplayMode { + case automatic + case manual(shouldDisplay: Bool) + } + private static let bottomSpacerIdentifier = "Bottom Spacer" @Binding private var chat: Chat @Binding private var bottomPadding: CGFloat private let hideMessagesWithRoles: Set + private let typingIndicator: TypingIndicatorDisplayMode? private var keyboardPublisher: AnyPublisher { @@ -53,6 +65,16 @@ public struct MessagesView: View { .eraseToAnyPublisher() } + private var shouldDisplayTypingIndicator: Bool { + switch self.typingIndicator { + case .automatic: + self.chat.last?.role == .user + case .manual(let shouldDisplay): + shouldDisplay + case .none: + false + } + } public var body: some View { ScrollViewReader { scrollViewProxy in @@ -61,6 +83,9 @@ public struct MessagesView: View { ForEach(Array(chat.enumerated()), id: \.offset) { _, message in MessageView(message, hideMessagesWithRoles: hideMessagesWithRoles) } + if shouldDisplayTypingIndicator { + TypingIndicator() + } Spacer() .frame(height: bottomPadding) .id(MessagesView.bottomSpacerIdentifier) @@ -83,28 +108,34 @@ public struct MessagesView: View { /// - Parameters: /// - chat: The chat messages that should be displayed. /// - bottomPadding: A fixed bottom padding for the messages view. + /// - typingIndicator: Indicates whether a "three dots" animation should be automatically or manually shown; default value of `nil` will result in no indicator being shown under any condition. /// - hideMessagesWithRoles: The .system and .function roles are hidden from message view public init( _ chat: Chat, hideMessagesWithRoles: Set = MessageView.Defaults.hideMessagesWithRoles, + typingIndicator: TypingIndicatorDisplayMode? = nil, bottomPadding: CGFloat = 0 ) { self._chat = .constant(chat) self.hideMessagesWithRoles = hideMessagesWithRoles + self.typingIndicator = typingIndicator self._bottomPadding = .constant(bottomPadding) } /// - Parameters: /// - chat: The chat messages that should be displayed. /// - bottomPadding: A bottom padding for the messages view. + /// - typingIndicator: Indicates whether a "three dots" animation should be automatically or manually shown; default value of `nil` will result in no indicator being shown under any condition. /// - hideMessagesWithRoles: Defines which messages should be hidden based on the passed in message roles. public init( _ chat: Binding, hideMessagesWithRoles: Set = MessageView.Defaults.hideMessagesWithRoles, + typingIndicator: TypingIndicatorDisplayMode? = nil, bottomPadding: Binding = .constant(0) ) { self._chat = chat self.hideMessagesWithRoles = hideMessagesWithRoles + self.typingIndicator = typingIndicator self._bottomPadding = bottomPadding } diff --git a/Sources/SpeziChat/Resources/Localizable.xcstrings b/Sources/SpeziChat/Resources/Localizable.xcstrings index 6167937..ea18908 100644 --- a/Sources/SpeziChat/Resources/Localizable.xcstrings +++ b/Sources/SpeziChat/Resources/Localizable.xcstrings @@ -86,6 +86,16 @@ } } } + }, + "TYPING_INDICATOR" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Typing Indicator" + } + } + } } }, "version" : "1.0" diff --git a/Sources/SpeziChat/SpeziChat.docc/SpeziChat.md b/Sources/SpeziChat/SpeziChat.docc/SpeziChat.md index db95e06..c143343 100644 --- a/Sources/SpeziChat/SpeziChat.docc/SpeziChat.md +++ b/Sources/SpeziChat/SpeziChat.docc/SpeziChat.md @@ -59,7 +59,7 @@ These entries are mandatory for apps that utilize microphone and speech recognit ### Chat View -The ``ChatView`` provides a basic reusable chat view which includes a message input field. The input can be either typed out via the iOS keyboard or provided as voice input and transcribed into written text. +The ``ChatView`` provides a basic reusable chat view which includes a message input field. The input can be either typed out via the iOS keyboard or provided as voice input and transcribed into written text. It accepts an additional `messagePendingAnimation` parameter to control whether a chat bubble animation is shown for a message that is currently being composed. By default, `messagePendingAnimation` has a value of `nil` and does not show. In addition, the ``ChatView`` provides functionality to export the visualized ``Chat`` as a PDF document, JSON representation, or textual UTF-8 file (see ``ChatView/ChatExportFormat``) via a Share Sheet (or Activity View). ```swift @@ -80,6 +80,7 @@ struct ChatTestView: View { The ``MessagesView`` displays a ``Chat`` containing multiple ``ChatEntity``s with different ``ChatEntity/Role``s in a typical chat-like fashion. The `View` automatically scrolls down to the newest message that is added to the passed ``Chat`` SwiftUI `Binding`. +The `typingIndicator` parameter controls when a typing indicator is shown onscreen for incoming messages to `Chat`. ```swift struct MessagesViewTestView: View { diff --git a/Tests/UITests/TestApp/ChatTestView.swift b/Tests/UITests/TestApp/ChatTestView.swift index 1052e4c..1a54c33 100644 --- a/Tests/UITests/TestApp/ChatTestView.swift +++ b/Tests/UITests/TestApp/ChatTestView.swift @@ -17,14 +17,14 @@ struct ChatTestView: View { var body: some View { - ChatView($chat, exportFormat: .pdf) + ChatView($chat, exportFormat: .pdf, messagePendingAnimation: .automatic) .navigationTitle("SpeziChat") .padding(.top, 16) .onChange(of: chat) { _, newValue in /// Append a new assistant message to the chat after sleeping for 1 second. if newValue.last?.role == .user { Task { - try await Task.sleep(for: .seconds(1)) + try await Task.sleep(for: .seconds(5)) await MainActor.run { chat.append(.init(role: .assistant, content: "Assistant Message Response!")) diff --git a/Tests/UITests/TestAppUITests/TestAppUITests.swift b/Tests/UITests/TestAppUITests/TestAppUITests.swift index 43b2e1d..5681413 100644 --- a/Tests/UITests/TestAppUITests/TestAppUITests.swift +++ b/Tests/UITests/TestAppUITests/TestAppUITests.swift @@ -33,6 +33,10 @@ class TestAppUITests: XCTestCase { sleep(1) + XCTAssert(app.otherElements["Typing Indicator"].waitForExistence(timeout: 2)) + + sleep(4) + XCTAssert(app.staticTexts["Assistant Message Response!"].waitForExistence(timeout: 5)) }