Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions DemoAppSwiftUI/AppleMessageComposerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ struct AppleMessageComposerView<Factory: ViewFactory>: View, KeyboardReadable {
maxMessageLength: channelConfig?.maxMessageLength,
cooldownDuration: viewModel.cooldownDuration,
onCustomAttachmentTap: viewModel.customAttachmentTapped(_:),
removeAttachmentWithId: viewModel.removeAttachment(with:)
removeAttachmentWithId: viewModel.removeAttachment(with:),
sendMessage: {}
)
.overlay(
viewModel.sendButtonEnabled ? sendButton : nil
Expand Down Expand Up @@ -168,7 +169,7 @@ struct AppleMessageComposerView<Factory: ViewFactory>: View, KeyboardReadable {
.animation(.none, value: viewModel.showCommandsOverlay) : nil,
alignment: .bottom
)
.modifier(factory.makeComposerViewModifier(options: ComposerViewModifierOptions()))
.modifier(factory.styles.makeComposerViewModifier(options: ComposerViewModifierOptions()))
.onChange(of: editedMessage) { _ in
viewModel.text = editedMessage?.text ?? ""
if editedMessage != nil {
Expand Down
2 changes: 2 additions & 0 deletions DemoAppSwiftUI/CustomComposerAttachmentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ extension ContactAttachmentPayload: Identifiable {

class CustomAttachmentsFactory: ViewFactory {
@Injected(\.chatClient) var chatClient: ChatClient

public var styles = LiquidGlassStyles()

private let mockContacts = [
CustomAttachment(
Expand Down
6 changes: 5 additions & 1 deletion DemoAppSwiftUI/ViewFactoryExamples.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ class DemoAppFactory: ViewFactory {
private var mentionsHandler = MentionsHandler()

public static let shared = DemoAppFactory()

public var styles = LiquidGlassStyles()

func makeChannelListHeaderViewModifier(options: ChannelListHeaderViewModifierOptions) -> some ChannelListHeaderViewModifier {
CustomChannelModifier(title: options.title)
Expand Down Expand Up @@ -162,7 +164,7 @@ struct ShowProfileModifier: ViewModifier {
func body(content: Content) -> some View {
content
.modifier(
DefaultViewFactory.shared.makeMessageViewModifier(for: messageModifierInfo)
DefaultViewFactory.shared.styles.makeMessageViewModifier(for: messageModifierInfo)
)
.modifier(
ProfileURLModifier(
Expand Down Expand Up @@ -230,6 +232,8 @@ struct CustomChannelDestination: View {
class CustomFactory: ViewFactory {
@Injected(\.chatClient) public var chatClient

public var styles = LiquidGlassStyles()

private init() {}

public static let shared = CustomFactory()
Expand Down
2 changes: 2 additions & 0 deletions DemoAppSwiftUI/iMessagePocView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ class iMessageViewFactory: ViewFactory {

static let shared = iMessageViewFactory()

public var styles = LiquidGlassStyles()

private init() {}

func makeLeadingSwipeActionsView(
Expand Down
99 changes: 81 additions & 18 deletions Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,19 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
@State private var messageDisplayInfo: MessageDisplayInfo?
@State private var keyboardShown = false
@State private var tabBarAvailable: Bool = false

@State private var floatingComposerHeight: CGFloat

private var factory: Factory

public init(
viewFactory: Factory = DefaultViewFactory.shared,
viewModel: ChatChannelViewModel? = nil,
channelController: ChatChannelController,
messageController: ChatMessageController? = nil,
scrollToMessage: ChatMessage? = nil
scrollToMessage: ChatMessage? = nil,
composerPlacement: ComposerPlacement = .floating
) {
_floatingComposerHeight = State(initialValue: Self.defaultFloatingComposerHeight())
_viewModel = StateObject(
wrappedValue: viewModel ?? ViewModelsFactory.makeChannelViewModel(
with: channelController,
Expand Down Expand Up @@ -55,6 +58,7 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
listId: viewModel.listId,
isMessageThread: viewModel.isMessageThread,
shouldShowTypingIndicator: viewModel.shouldShowTypingIndicator,
bottomInset: composerPlacement == .floating ? floatingComposerHeight : 0,
scrollPosition: $viewModel.scrollPosition,
loadingNextMessages: viewModel.loadingNextMessages,
firstUnreadMessageId: $viewModel.firstUnreadMessageId,
Expand All @@ -73,6 +77,7 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
},
onJumpToMessage: viewModel.jumpToMessage(messageId:)
)
.edgesIgnoringSafeArea(.bottom)
.environment(\.highlightedMessageId, viewModel.highlightedMessageId)
.dismissKeyboardOnTap(enabled: true) {
hideComposerCommandsAndAttachmentsPicker()
Expand Down Expand Up @@ -100,6 +105,7 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
}

Divider()
.opacity(composerPlacement == .docked ? 1 : 0)
.navigationBarBackButtonHidden(viewModel.reactionsShown)
.if(viewModel.reactionsShown, transform: { view in
view.modifier(factory.makeChannelBarsVisibilityViewModifier(options: ChannelBarsVisibilityViewModifierOptions(shouldShow: false)))
Expand All @@ -118,21 +124,13 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
}
.animation(nil)

factory.makeMessageComposerViewType(
options: MessageComposerViewTypeOptions(
channelController: viewModel.channelController,
messageController: viewModel.messageController,
quotedMessage: $viewModel.quotedMessage,
editedMessage: $viewModel.editedMessage,
onMessageSent: {
viewModel.messageSentTapped()
}
)
)
.opacity((
utils.messageListConfig.messagePopoverEnabled && messageDisplayInfo != nil && !viewModel
.reactionsShown && viewModel.channel?.isFrozen == false
) ? 0 : 1)
if composerPlacement == .docked {
composerView
.opacity((
utils.messageListConfig.messagePopoverEnabled && messageDisplayInfo != nil && !viewModel
.reactionsShown && viewModel.channel?.isFrozen == false
) ? 0 : 1)
}

NavigationLink(
isActive: $viewModel.threadMessageShown
Expand Down Expand Up @@ -171,6 +169,13 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
.edgesIgnoringSafeArea(.all)
: nil
)
.modifier(FloatingComposerContainer(
composerPlacement: composerPlacement,
composer: {
composerView
.opacity(viewModel.reactionsShown ? 0 : 1)
}
))
} else {
factory.makeChannelLoadingView(options: ChannelLoadingViewOptions())
}
Expand Down Expand Up @@ -209,9 +214,27 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
.alertBanner(isPresented: $viewModel.showAlertBanner)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("ChatChannelView")
.modifier(factory.makeBouncedMessageActionsModifier(viewModel: viewModel))
.modifier(factory.styles.makeBouncedMessageActionsModifier(viewModel: viewModel))
.accentColor(colors.tintColor)
}

private var composerView: some View {
factory.makeMessageComposerViewType(
options: MessageComposerViewTypeOptions(
channelController: viewModel.channelController,
messageController: viewModel.messageController,
quotedMessage: $viewModel.quotedMessage,
editedMessage: $viewModel.editedMessage,
onMessageSent: {
viewModel.messageSentTapped()
}
)
)
}

private var composerPlacement: ComposerPlacement {
factory.styles.composerPlacement
}

private var generatingSnapshot: Bool {
if #available(iOS 26, *) {
Expand All @@ -235,3 +258,43 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
)
}
}

public enum ComposerPlacement {
case docked
case floating
}

private extension ChatChannelView {
static func defaultFloatingComposerHeight() -> CGFloat {
let utils = InjectedValues[\.utils]
let baseHeight = utils.composerConfig.inputViewMinHeight
let outerPadding: CGFloat = 16 // HStack padding (.all, 8)
return baseHeight + outerPadding + 10
}
}

private struct FloatingComposerContainer<Composer: View>: ViewModifier {
let composerPlacement: ComposerPlacement
let composer: () -> Composer

func body(content: Content) -> some View {
if composerPlacement == .docked {
content
} else {
if #available(iOS 15.0, *) {
content
.overlay(alignment: .bottom) {
composer()
}
} else {
content
.overlay(
VStack {
Spacer()
composer()
}
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import SwiftUI
private var canMarkRead = false
private var hasSetInitialCanMarkRead = false
private var currentUserSentNewMessage = false
private var skipScrollUpdates = true

private let messageListDateOverlay: DateFormatter = DateFormatter.messageListDateOverlay

Expand Down Expand Up @@ -230,6 +231,11 @@ import SwiftUI
object: nil
)
}

// TODO: improve this.
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: { [weak self] in
self?.skipScrollUpdates = false
})

channelName = channel?.name ?? ""
checkHeaderType()
Expand Down Expand Up @@ -822,6 +828,7 @@ import SwiftUI
}

private func updateScrolledIdToNewestMessage() {
guard !skipScrollUpdates else { return }
if scrolledId != nil {
scrolledId = nil
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import StreamChat
import SwiftUI

struct LeadingComposerView<Factory: ViewFactory>: View {
@Injected(\.colors) var colors

var factory: Factory

@Binding var pickerTypeState: PickerTypeState
public let channelConfig: ChannelConfig?

var body: some View {
HStack {
if #available(iOS 26.0, *) {
Button {
withAnimation {
if pickerTypeState == .collapsed || pickerTypeState == .expanded(.none) {
pickerTypeState = .expanded(.media)
} else {
pickerTypeState = .expanded(.none)
}
}
} label: {
Image(systemName: "plus")
.fontWeight(.semibold)
}
.padding(.all, 12)
.modifier(factory.styles.makeComposerButtonViewModifier(options: .init()))
.foregroundStyle(.primary)
.contentShape(.rect)
} else {
Button {
withAnimation {
if pickerTypeState == .collapsed || pickerTypeState == .expanded(.none) {
pickerTypeState = .expanded(.media)
} else {
pickerTypeState = .expanded(.none)
}
}
} label: {
Image(systemName: "plus")
}
.padding(.all, 12)
.background(Color(UIColor.secondarySystemBackground))
.clipShape(.circle)
.contentShape(.rect)
}
}
.padding(.leading, 8)
}
}
Loading
Loading