Skip to content

Commit

Permalink
Add Binding<Bool> and animation in ChatView for three-dots typing…
Browse files Browse the repository at this point in the history
… indicator (#6)

# Chat Typing Indicator

## ♻️ Current situation & Problem
As stated in [#15](StanfordSpezi/SpeziLLM#15),
there is currently no visual indicator when a user input is being
processed by the LLM on the other end. This can lead to a confusing
experience, as the user may prematurely send another message or exit the
app, believing that the chat has stopped responding.


## ⚙️ Release Notes 
- Extended `MessageView` to add the typing indicator animation, which is
visible depending on a new variable called `displayTypingIndicator`.
- Typing indicator displays three gray dots in a message bubble at the
bottom of the chat when an LLM response is pending (i.e., the first
token has not yet been streamed).
- Each dot appears and disappears in a sequential, wave-like pattern,
creating the impression that the dots are bouncing or pulsing one after
the other from left to right. The animation loops continuously until
`displayTypingIndicator` is set to `false`.


## 📚 Documentation
Documentation for the new initializer associated with this update is
provided as an in-code comment.


## ✅ Testing
Manually tested with SwiftUI Previews.


## 📝 Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md).

---------

Co-authored-by: Philipp Zagar <[email protected]>
  • Loading branch information
MatthewTurk247 and philippzagar authored Jan 26, 2024
1 parent 9d45c10 commit ea5e21b
Show file tree
Hide file tree
Showing 9 changed files with 126 additions and 6 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions Sources/SpeziChat/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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<Chat>,
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
}
}

Expand Down
69 changes: 69 additions & 0 deletions Sources/SpeziChat/Helpers/TypingIndicator.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
31 changes: 31 additions & 0 deletions Sources/SpeziChat/MessagesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatEntity.Role>
private let typingIndicator: TypingIndicatorDisplayMode?


private var keyboardPublisher: AnyPublisher<Bool, Never> {
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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<ChatEntity.Role> = 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<Chat>,
hideMessagesWithRoles: Set<ChatEntity.Role> = MessageView.Defaults.hideMessagesWithRoles,
typingIndicator: TypingIndicatorDisplayMode? = nil,
bottomPadding: Binding<CGFloat> = .constant(0)
) {
self._chat = chat
self.hideMessagesWithRoles = hideMessagesWithRoles
self.typingIndicator = typingIndicator
self._bottomPadding = bottomPadding
}

Expand Down
10 changes: 10 additions & 0 deletions Sources/SpeziChat/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@
}
}
}
},
"TYPING_INDICATOR" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Typing Indicator"
}
}
}
}
},
"version" : "1.0"
Expand Down
3 changes: 2 additions & 1 deletion Sources/SpeziChat/SpeziChat.docc/SpeziChat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions Tests/UITests/TestApp/ChatTestView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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!"))
Expand Down
4 changes: 4 additions & 0 deletions Tests/UITests/TestAppUITests/TestAppUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down

0 comments on commit ea5e21b

Please sign in to comment.