Skip to content

Commit 07d4e0e

Browse files
authored
Allow dismissing commands overlay and attachments picker on message list tap (#1024)
* Hide commands overlay when keyboard disappears * Allow hiding keyboard attachments picker when tapping the message list * Update CHANGELOG.md * Use the same implementation strategy for the commands overlay * Rename the notifications names to be more consistent with the rest of the codebase * Allow the attachments picker by default as well * Add test coverage to the view
1 parent a0b9f51 commit 07d4e0e

File tree

8 files changed

+227
-7
lines changed

8 files changed

+227
-7
lines changed

CHANGELOG.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
55

66
### ✅ Added
77
- Add the `makeAttachmentTextView` method to ViewFactory [#1013](https://github.com/GetStream/stream-chat-swiftui/pull/1013)
8-
8+
- Allow dismissing commands overlay when tapping the message list [#1024](https://github.com/GetStream/stream-chat-swiftui/pull/1024)
9+
- Allows dismissing the keyboard attachments picker when tapping the message list [#1024](https://github.com/GetStream/stream-chat-swiftui/pull/1024)
910
### 🐞 Fixed
1011
- Fix composer not being locked after the channel was frozen [#1015](https://github.com/GetStream/stream-chat-swiftui/pull/1015)
1112

12-
### 🔄 Changed
13-
1413
# [4.90.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.90.0)
1514
_October 08, 2025_
1615

Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
7373
},
7474
onJumpToMessage: viewModel.jumpToMessage(messageId:)
7575
)
76+
.dismissKeyboardOnTap(enabled: true) {
77+
hideComposerCommandsAndAttachmentsPicker()
78+
}
7679
.overlay(
7780
viewModel.currentDateString != nil ?
7881
factory.makeDateIndicatorView(dateString: viewModel.currentDateString!)
@@ -81,7 +84,9 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
8184
} else {
8285
ZStack {
8386
factory.makeEmptyMessagesView(for: channel, colors: colors)
84-
.dismissKeyboardOnTap(enabled: keyboardShown)
87+
.dismissKeyboardOnTap(enabled: keyboardShown) {
88+
hideComposerCommandsAndAttachmentsPicker()
89+
}
8590
if viewModel.shouldShowTypingIndicator {
8691
factory.makeTypingIndicatorBottomView(
8792
channel: channel,
@@ -213,4 +218,13 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
213218
let bottomPadding = topVC()?.view.safeAreaInsets.bottom ?? 0
214219
return bottomPadding
215220
}
221+
222+
private func hideComposerCommandsAndAttachmentsPicker() {
223+
NotificationCenter.default.post(
224+
name: .attachmentPickerHiddenNotification, object: nil
225+
)
226+
NotificationCenter.default.post(
227+
name: .commandsOverlayHiddenNotification, object: nil
228+
)
229+
}
216230
}

Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import SwiftUI
99
public struct MessageComposerView<Factory: ViewFactory>: View, KeyboardReadable {
1010
@Injected(\.colors) private var colors
1111
@Injected(\.fonts) private var fonts
12+
@Injected(\.utils) private var utils
1213

1314
// Initial popup size, before the keyboard is shown.
1415
@State private var popupSize: CGFloat = 350
@@ -228,6 +229,18 @@ public struct MessageComposerView<Factory: ViewFactory>: View, KeyboardReadable
228229
viewModel.updateDraftMessage(quotedMessage: quotedMessage)
229230
}
230231
})
232+
.onReceive(NotificationCenter.default.publisher(for: .commandsOverlayHiddenNotification)) { _ in
233+
guard utils.messageListConfig.hidesCommandsOverlayOnMessageListTap else {
234+
return
235+
}
236+
viewModel.composerCommand = nil
237+
}
238+
.onReceive(NotificationCenter.default.publisher(for: .attachmentPickerHiddenNotification)) { _ in
239+
guard utils.messageListConfig.hidesAttachmentsPickersOnMessageListTap else {
240+
return
241+
}
242+
viewModel.pickerTypeState = .expanded(.none)
243+
}
231244
.accessibilityElement(children: .contain)
232245
}
233246
}
@@ -444,3 +457,13 @@ public struct ComposerInputView<Factory: ViewFactory>: View, KeyboardReadable {
444457
isInCooldown || isChannelFrozen
445458
}
446459
}
460+
461+
// MARK: - Notification Names
462+
463+
extension Notification.Name {
464+
/// Notification sent when the attachments picker should be hidden.
465+
static let attachmentPickerHiddenNotification = Notification.Name("attachmentPickerHiddenNotification")
466+
467+
/// Notification sent when the commands overlay should be hidden.
468+
static let commandsOverlayHiddenNotification = Notification.Name("commandsOverlayHiddenNotification")
469+
}

Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ public struct MessageListConfig {
3636
bouncedMessagesAlertActionsEnabled: Bool = true,
3737
skipEditedMessageLabel: @escaping (ChatMessage) -> Bool = { _ in false },
3838
draftMessagesEnabled: Bool = false,
39-
downloadFileAttachmentsEnabled: Bool = false
39+
downloadFileAttachmentsEnabled: Bool = false,
40+
hidesCommandsOverlayOnMessageListTap: Bool = true,
41+
hidesAttachmentsPickersOnMessageListTap: Bool = true
4042
) {
4143
self.messageListType = messageListType
4244
self.typingIndicatorPlacement = typingIndicatorPlacement
@@ -66,6 +68,8 @@ public struct MessageListConfig {
6668
self.skipEditedMessageLabel = skipEditedMessageLabel
6769
self.draftMessagesEnabled = draftMessagesEnabled
6870
self.downloadFileAttachmentsEnabled = downloadFileAttachmentsEnabled
71+
self.hidesCommandsOverlayOnMessageListTap = hidesCommandsOverlayOnMessageListTap
72+
self.hidesAttachmentsPickersOnMessageListTap = hidesAttachmentsPickersOnMessageListTap
6973
}
7074

7175
public let messageListType: MessageListType
@@ -93,6 +97,16 @@ public struct MessageListConfig {
9397
public let markdownSupportEnabled: Bool
9498
public let userBlockingEnabled: Bool
9599

100+
/// A boolean to enable hiding the commands overlay when tapping the message list.
101+
///
102+
/// It is enabled by default.
103+
public let hidesCommandsOverlayOnMessageListTap: Bool
104+
105+
/// A boolean to enable hiding the attachments keyboard picker when tapping the message list.
106+
///
107+
/// It is enabled by default.
108+
public let hidesAttachmentsPickersOnMessageListTap: Bool
109+
96110
/// A boolean to enable the alert actions for bounced messages.
97111
///
98112
/// By default it is true and the bounced actions are displayed as an alert instead of a context menu.

Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,6 @@ public struct MessageListView<Factory: ViewFactory>: View, KeyboardReadable {
313313
) : nil
314314
)
315315
.modifier(factory.makeMessageListContainerModifier())
316-
.dismissKeyboardOnTap(enabled: keyboardShown)
317316
.onDisappear {
318317
messageRenderingUtil.update(previousTopMessage: nil)
319318
}

Sources/StreamChatSwiftUI/Utils/KeyboardHandling.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ extension View {
6363
/// - enabled: If true, tapping on the view dismisses the view, otherwise keyboard stays visible.
6464
/// - onTapped: A closure which is triggered when keyboard is dismissed after tapping the view.
6565
func dismissKeyboardOnTap(enabled: Bool, onKeyboardDismissed: (() -> Void)? = nil) -> some View {
66-
modifier(HideKeyboardOnTapGesture(shouldAdd: enabled))
66+
modifier(HideKeyboardOnTapGesture(shouldAdd: enabled, onTapped: onKeyboardDismissed))
6767
}
6868
}
6969

StreamChatSwiftUITests/Infrastructure/TestTools/ViewFrameUtils.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,15 @@ extension View {
1515
func applySize(_ size: CGSize) -> some View {
1616
frame(width: size.width, height: size.height)
1717
}
18+
19+
@discardableResult
20+
/// Add SwiftUI View to a fake hierarchy so that it can receive UI events.
21+
func addToViewHierarchy() -> some View {
22+
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 400))
23+
let hostingController = UIHostingController(rootView: self)
24+
window.rootViewController = hostingController
25+
window.makeKeyAndVisible()
26+
hostingController.view.layoutIfNeeded()
27+
return self
28+
}
1829
}

StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -894,6 +894,166 @@ class MessageComposerView_Tests: StreamChatTestCase {
894894
onMessageSent: {}
895895
)
896896
}
897+
898+
// MARK: - Notification Tests
899+
900+
func test_commandsOverlayHiddenNotification_hidesCommandsOverlay() {
901+
// Given
902+
let utils = Utils(
903+
messageListConfig: MessageListConfig(
904+
hidesCommandsOverlayOnMessageListTap: true
905+
)
906+
)
907+
streamChat = StreamChat(chatClient: chatClient, utils: utils)
908+
909+
let factory = DefaultViewFactory.shared
910+
let channelController = ChatChannelTestHelpers.makeChannelController(chatClient: chatClient)
911+
let viewModel = MessageComposerViewModel(channelController: channelController, messageController: nil)
912+
913+
// Set up a command to be shown
914+
viewModel.composerCommand = ComposerCommand(
915+
id: "testCommand",
916+
typingSuggestion: TypingSuggestion.empty,
917+
displayInfo: nil
918+
)
919+
920+
let view = MessageComposerView(
921+
viewFactory: factory,
922+
viewModel: viewModel,
923+
channelController: channelController,
924+
messageController: nil,
925+
quotedMessage: .constant(nil),
926+
editedMessage: .constant(nil),
927+
onMessageSent: {}
928+
)
929+
view.addToViewHierarchy()
930+
931+
// When
932+
NotificationCenter.default.post(
933+
name: .commandsOverlayHiddenNotification,
934+
object: nil
935+
)
936+
937+
// Then
938+
XCTAssertNil(viewModel.composerCommand)
939+
}
940+
941+
func test_commandsOverlayHiddenNotification_respectsConfigSetting() {
942+
// Given
943+
let utils = Utils(
944+
messageListConfig: MessageListConfig(
945+
hidesCommandsOverlayOnMessageListTap: false
946+
)
947+
)
948+
streamChat = StreamChat(chatClient: chatClient, utils: utils)
949+
950+
let factory = DefaultViewFactory.shared
951+
let channelController = ChatChannelTestHelpers.makeChannelController(chatClient: chatClient)
952+
let viewModel = MessageComposerViewModel(channelController: channelController, messageController: nil)
953+
954+
// Set up a command to be shown
955+
let testCommand = ComposerCommand(
956+
id: "testCommand",
957+
typingSuggestion: TypingSuggestion.empty,
958+
displayInfo: nil
959+
)
960+
viewModel.composerCommand = testCommand
961+
962+
let view = MessageComposerView(
963+
viewFactory: factory,
964+
viewModel: viewModel,
965+
channelController: channelController,
966+
messageController: nil,
967+
quotedMessage: .constant(nil),
968+
editedMessage: .constant(nil),
969+
onMessageSent: {}
970+
)
971+
view.addToViewHierarchy()
972+
973+
// When
974+
NotificationCenter.default.post(
975+
name: .commandsOverlayHiddenNotification,
976+
object: nil
977+
)
978+
979+
// Then
980+
XCTAssertNotNil(viewModel.composerCommand)
981+
XCTAssertEqual(viewModel.composerCommand?.id, testCommand.id)
982+
}
983+
984+
func test_attachmentPickerHiddenNotification_hidesAttachmentPicker() {
985+
// Given
986+
let utils = Utils(
987+
messageListConfig: MessageListConfig(
988+
hidesAttachmentsPickersOnMessageListTap: true
989+
)
990+
)
991+
streamChat = StreamChat(chatClient: chatClient, utils: utils)
992+
993+
let factory = DefaultViewFactory.shared
994+
let channelController = ChatChannelTestHelpers.makeChannelController(chatClient: chatClient)
995+
let viewModel = MessageComposerViewModel(channelController: channelController, messageController: nil)
996+
997+
// Set up attachment picker to be shown
998+
viewModel.pickerTypeState = .expanded(.media)
999+
1000+
let view = MessageComposerView(
1001+
viewFactory: factory,
1002+
viewModel: viewModel,
1003+
channelController: channelController,
1004+
messageController: nil,
1005+
quotedMessage: .constant(nil),
1006+
editedMessage: .constant(nil),
1007+
onMessageSent: {}
1008+
)
1009+
view.addToViewHierarchy()
1010+
1011+
// When
1012+
NotificationCenter.default.post(
1013+
name: .attachmentPickerHiddenNotification,
1014+
object: nil
1015+
)
1016+
1017+
// Then
1018+
XCTAssertEqual(viewModel.pickerTypeState, .expanded(.none))
1019+
}
1020+
1021+
func test_attachmentPickerHiddenNotification_respectsConfigSetting() {
1022+
// Given
1023+
let utils = Utils(
1024+
messageListConfig: MessageListConfig(
1025+
hidesAttachmentsPickersOnMessageListTap: false
1026+
)
1027+
)
1028+
streamChat = StreamChat(chatClient: chatClient, utils: utils)
1029+
1030+
let factory = DefaultViewFactory.shared
1031+
let channelController = ChatChannelTestHelpers.makeChannelController(chatClient: chatClient)
1032+
let viewModel = MessageComposerViewModel(channelController: channelController, messageController: nil)
1033+
1034+
// Set up attachment picker to be shown
1035+
viewModel.pickerTypeState = .expanded(.media)
1036+
1037+
let view = MessageComposerView(
1038+
viewFactory: factory,
1039+
viewModel: viewModel,
1040+
channelController: channelController,
1041+
messageController: nil,
1042+
quotedMessage: .constant(nil),
1043+
editedMessage: .constant(nil),
1044+
onMessageSent: {}
1045+
)
1046+
view.addToViewHierarchy()
1047+
1048+
// When
1049+
NotificationCenter.default.post(
1050+
name: .attachmentPickerHiddenNotification,
1051+
object: nil
1052+
)
1053+
1054+
// Then
1055+
XCTAssertEqual(viewModel.pickerTypeState, .expanded(.media))
1056+
}
8971057
}
8981058

8991059
class SynchronousAttachmentsConverter: MessageAttachmentsConverter {

0 commit comments

Comments
 (0)