diff --git a/CHANGELOG.md b/CHANGELOG.md index c8298054..907c6de7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming -### 🔄 Changed +### ✅ Added +- Add factory method to customize the channel avatar [#734](https://github.com/GetStream/stream-chat-swiftui/pull/734) # [4.71.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.71.0) _January 28, 2025_ diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelHeader/ChatChannelHeaderViewModifier.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelHeader/ChatChannelHeaderViewModifier.swift index 03c4eeed..4df3cdc2 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChannelHeader/ChatChannelHeaderViewModifier.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChannelHeader/ChatChannelHeaderViewModifier.swift @@ -11,7 +11,7 @@ public protocol ChatChannelHeaderViewModifier: ViewModifier { } /// The default channel header. -public struct DefaultChatChannelHeader: ToolbarContent { +public struct DefaultChatChannelHeader: ToolbarContent { @Injected(\.fonts) private var fonts @Injected(\.utils) private var utils @Injected(\.colors) private var colors @@ -34,15 +34,18 @@ public struct DefaultChatChannelHeader: ToolbarContent { .isEmpty } + private var factory: Factory public var channel: ChatChannel public var headerImage: UIImage @Binding public var isActive: Bool public init( + factory: Factory = DefaultViewFactory.shared, channel: ChatChannel, headerImage: UIImage, isActive: Binding ) { + self.factory = factory self.channel = channel self.headerImage = headerImage _isActive = isActive @@ -64,10 +67,13 @@ public struct DefaultChatChannelHeader: ToolbarContent { resignFirstResponder() isActive = true } label: { - ChannelAvatarView( - avatar: headerImage, - showOnlineIndicator: onlineIndicatorShown, - size: CGSize(width: 36, height: 36) + factory.makeChannelAvatarView( + for: channel, + with: .init( + showOnlineIndicator: onlineIndicatorShown, + size: CGSize(width: 36, height: 36), + avatar: headerImage + ) ) .offset(x: 4) } @@ -86,19 +92,25 @@ public struct DefaultChatChannelHeader: ToolbarContent { } /// The default header modifier. -public struct DefaultChannelHeaderModifier: ChatChannelHeaderViewModifier { +public struct DefaultChannelHeaderModifier: ChatChannelHeaderViewModifier { @ObservedObject private var channelHeaderLoader = InjectedValues[\.utils].channelHeaderLoader @State private var isActive: Bool = false + private var factory: Factory public var channel: ChatChannel - public init(channel: ChatChannel) { + public init( + factory: Factory = DefaultViewFactory.shared, + channel: ChatChannel + ) { + self.factory = factory self.channel = channel } public func body(content: Content) -> some View { content.toolbar { DefaultChatChannelHeader( + factory: factory, channel: channel, headerImage: channelHeaderLoader.image(for: channel), isActive: $isActive diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift index 1eaa1c0b..27ca4a9b 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift @@ -6,7 +6,7 @@ import StreamChat import SwiftUI /// View for the channel list item. -public struct ChatChannelListItem: View { +public struct ChatChannelListItem: View { @Injected(\.fonts) private var fonts @Injected(\.colors) private var colors @@ -14,6 +14,7 @@ public struct ChatChannelListItem: View { @Injected(\.images) private var images @Injected(\.chatClient) private var chatClient + var factory: Factory var channel: ChatChannel var channelName: String var injectedChannelInfo: InjectedChannelInfo? @@ -23,6 +24,7 @@ public struct ChatChannelListItem: View { var onItemTap: (ChatChannel) -> Void public init( + factory: Factory = DefaultViewFactory.shared, channel: ChatChannel, channelName: String, injectedChannelInfo: InjectedChannelInfo? = nil, @@ -31,6 +33,7 @@ public struct ChatChannelListItem: View { disabled: Bool = false, onItemTap: @escaping (ChatChannel) -> Void ) { + self.factory = factory self.channel = channel self.channelName = channelName self.injectedChannelInfo = injectedChannelInfo @@ -45,9 +48,9 @@ public struct ChatChannelListItem: View { onItemTap(channel) } label: { HStack { - ChannelAvatarView( - channel: channel, - showOnlineIndicator: onlineIndicatorShown + factory.makeChannelAvatarView( + for: channel, + with: .init(showOnlineIndicator: onlineIndicatorShown) ) VStack(alignment: .leading, spacing: 4) { @@ -127,6 +130,16 @@ public struct ChatChannelListItem: View { } } +/// Options for setting up the channel avatar view. +public struct ChannelAvatarViewOptions { + /// Whether the online indicator should be shown. + public var showOnlineIndicator: Bool + /// Size of the avatar. + public var size: CGSize = .defaultAvatarSize + /// Optional avatar image. If not provided, it will be loaded by the channel header loader. + public var avatar: UIImage? +} + /// View for the avatar used in channels (includes online indicator overlay). public struct ChannelAvatarView: View { @Injected(\.utils) private var utils @@ -157,9 +170,10 @@ public struct ChannelAvatarView: View { public init( channel: ChatChannel, showOnlineIndicator: Bool, + avatar: UIImage? = nil, size: CGSize = .defaultAvatarSize ) { - avatar = nil + self.avatar = avatar self.channel = channel self.showOnlineIndicator = showOnlineIndicator self.size = size @@ -193,7 +207,7 @@ public struct ChannelAvatarView: View { } private func reloadAvatar() { - guard let channel else { return } + guard let channel, avatar == nil else { return } channelAvatar = utils.channelHeaderLoader.image(for: channel) } } diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelNavigatableListItem.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelNavigatableListItem.swift index 8a6f22fd..105c029b 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelNavigatableListItem.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelNavigatableListItem.swift @@ -7,7 +7,8 @@ import SwiftUI /// Chat channel list item that supports navigating to a destination. /// It's generic over the channel destination. -public struct ChatChannelNavigatableListItem: View { +public struct ChatChannelNavigatableListItem: View { + private var factory: Factory private var channel: ChatChannel private var channelName: String private var avatar: UIImage @@ -18,6 +19,7 @@ public struct ChatChannelNavigatableListItem: View { private var onItemTap: (ChatChannel) -> Void public init( + factory: Factory = DefaultViewFactory.shared, channel: ChatChannel, channelName: String, avatar: UIImage, @@ -27,6 +29,7 @@ public struct ChatChannelNavigatableListItem: View { channelDestination: @escaping (ChannelSelectionInfo) -> ChannelDestination, onItemTap: @escaping (ChatChannel) -> Void ) { + self.factory = factory self.channel = channel self.channelName = channelName self.channelDestination = channelDestination @@ -40,6 +43,7 @@ public struct ChatChannelNavigatableListItem: View { public var body: some View { ZStack { ChatChannelListItem( + factory: factory, channel: channel, channelName: channelName, injectedChannelInfo: injectedChannelInfo, diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/SearchResultsView.swift b/Sources/StreamChatSwiftUI/ChatChannelList/SearchResultsView.swift index c3b8199c..593576b6 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/SearchResultsView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/SearchResultsView.swift @@ -118,10 +118,11 @@ struct SearchResultView: View { } /// The search result item user interface. -struct SearchResultItem: View { +struct SearchResultItem: View { @Injected(\.utils) private var utils + var factory: Factory var searchResult: ChannelSelectionInfo var onlineIndicatorShown: Bool var channelName: String @@ -134,9 +135,9 @@ struct SearchResultItem: View { onSearchResultTap(searchResult) } label: { HStack { - ChannelAvatarView( - avatar: avatar, - showOnlineIndicator: onlineIndicatorShown + factory.makeChannelAvatarView( + for: searchResult.channel, + with: .init(showOnlineIndicator: onlineIndicatorShown, avatar: avatar) ) VStack(alignment: .leading, spacing: 4) { diff --git a/Sources/StreamChatSwiftUI/DefaultViewFactory.swift b/Sources/StreamChatSwiftUI/DefaultViewFactory.swift index f8dcb3a8..a230f0ad 100644 --- a/Sources/StreamChatSwiftUI/DefaultViewFactory.swift +++ b/Sources/StreamChatSwiftUI/DefaultViewFactory.swift @@ -74,6 +74,7 @@ extension ViewFactory { leadingSwipeButtonTapped: @escaping (ChatChannel) -> Void ) -> some View { let listItem = ChatChannelNavigatableListItem( + factory: self, channel: channel, channelName: channelName, avatar: avatar, @@ -95,6 +96,18 @@ extension ViewFactory { ) } + public func makeChannelAvatarView( + for channel: ChatChannel, + with options: ChannelAvatarViewOptions + ) -> some View { + ChannelAvatarView( + channel: channel, + showOnlineIndicator: options.showOnlineIndicator, + avatar: options.avatar, + size: options.size + ) + } + public func makeChannelListBackground(colors: ColorPalette) -> some View { Color(colors.background) .edgesIgnoringSafeArea(.bottom) @@ -189,6 +202,7 @@ extension ViewFactory { channelDestination: @escaping (ChannelSelectionInfo) -> ChannelDestination ) -> some View { SearchResultItem( + factory: self, searchResult: searchResult, onlineIndicatorShown: onlineIndicatorShown, channelName: channelName, @@ -280,7 +294,7 @@ extension ViewFactory { public func makeChannelHeaderViewModifier( for channel: ChatChannel ) -> some ChatChannelHeaderViewModifier { - DefaultChannelHeaderModifier(channel: channel) + DefaultChannelHeaderModifier(factory: self, channel: channel) } public func makeChannelLoadingView() -> some View { diff --git a/Sources/StreamChatSwiftUI/ViewFactory.swift b/Sources/StreamChatSwiftUI/ViewFactory.swift index 4c6293bc..f2141380 100644 --- a/Sources/StreamChatSwiftUI/ViewFactory.swift +++ b/Sources/StreamChatSwiftUI/ViewFactory.swift @@ -58,6 +58,17 @@ public protocol ViewFactory: AnyObject { trailingSwipeLeftButtonTapped: @escaping (ChatChannel) -> Void, leadingSwipeButtonTapped: @escaping (ChatChannel) -> Void ) -> ChannelListItemType + + associatedtype ChannelAvatarViewType: View + /// Creates the channel avatar view shown in the channel list, search results and the channel header. + /// - Parameters: + /// - channel: the channel where the avatar is displayed. + /// - options: the options used to configure the avatar view. + /// - Returns: view displayed in the channel avatar slot. + func makeChannelAvatarView( + for channel: ChatChannel, + with options: ChannelAvatarViewOptions + ) -> ChannelAvatarViewType associatedtype ChannelListBackground: View /// Creates the background for the channel list. diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelHeader_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelHeader_Tests.swift index baf0ec04..5c33f782 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelHeader_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelHeader_Tests.swift @@ -26,6 +26,27 @@ class ChatChannelHeader_Tests: StreamChatTestCase { // Then assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) } + + func test_chatChannelHeaderModifier_channelAvatarUpdated() { + // Given + let channel = ChatChannel.mockDMChannel(name: "Test channel") + + // When + let view = NavigationView { + Text("Test") + .applyDefaultSize() + .modifier( + DefaultChannelHeaderModifier( + factory: ChannelAvatarViewFactory(), + channel: channel + ) + ) + } + .applyDefaultSize() + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } func test_chatChannelHeader_snapshot() { // Given diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/ChatChannelHeader_Tests/test_chatChannelHeaderModifier_channelAvatarUpdated.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/ChatChannelHeader_Tests/test_chatChannelHeaderModifier_channelAvatarUpdated.1.png new file mode 100644 index 00000000..4db05a1b Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/ChatChannelHeader_Tests/test_chatChannelHeaderModifier_channelAvatarUpdated.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListView_Tests.swift index 3ddb30bf..a52e0f5b 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListView_Tests.swift @@ -71,6 +71,21 @@ class ChatChannelListView_Tests: StreamChatTestCase { // Then assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) } + + func test_channelListView_channelAvatarUpdated() { + // Given + let controller = makeChannelListController() + + // When + let view = ChatChannelListView( + viewFactory: ChannelAvatarViewFactory(), + channelListController: controller + ) + .applyDefaultSize() + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } private func makeChannelListController() -> ChatChannelListController_Mock { let channelListController = ChatChannelListController_Mock.mock(client: chatClient) @@ -87,3 +102,17 @@ class ChatChannelListView_Tests: StreamChatTestCase { return channels } } + +class ChannelAvatarViewFactory: ViewFactory { + + @Injected(\.chatClient) var chatClient + + func makeChannelAvatarView( + for channel: ChatChannel, + with options: ChannelAvatarViewOptions + ) -> some View { + Circle() + .fill(.red) + .frame(width: options.size.width, height: options.size.height) + } +} diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/SearchResultsView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannelList/SearchResultsView_Tests.swift index 9348ae81..aaa57f81 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannelList/SearchResultsView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannelList/SearchResultsView_Tests.swift @@ -142,4 +142,37 @@ class SearchResultsView_Tests: StreamChatTestCase { // Then assertSnapshot(matching: view, as: .image) } + + func test_searchResultsView_channelAvatarUpdated() { + // Given + let channel1 = ChatChannel.mock(cid: .unique, name: "Test 1") + let message1 = ChatMessage.mock( + id: .unique, + cid: .unique, + text: "Test 1", + author: .mock(id: .unique) + ) + let result1 = ChannelSelectionInfo( + channel: channel1, + message: message1 + ) + let searchResults = [result1] + + // When + let view = SearchResultsView( + factory: ChannelAvatarViewFactory(), + selectedChannel: .constant(nil), + searchResults: searchResults, + loadingSearchResults: false, + onlineIndicatorShown: { _ in false }, + channelNaming: { $0.name ?? "" }, + imageLoader: { _ in UIImage(systemName: "person.circle")! }, + onSearchResultTap: { _ in }, + onItemAppear: { _ in } + ) + .applyDefaultSize() + + // Then + assertSnapshot(matching: view, as: .image) + } } diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListView_Tests/test_channelListView_channelAvatarUpdated.1.png b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListView_Tests/test_channelListView_channelAvatarUpdated.1.png new file mode 100644 index 00000000..a0545690 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/ChatChannelListView_Tests/test_channelListView_channelAvatarUpdated.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/SearchResultsView_Tests/test_searchResultsView_channelAvatarUpdated.1.png b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/SearchResultsView_Tests/test_searchResultsView_channelAvatarUpdated.1.png new file mode 100644 index 00000000..51901fb8 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/SearchResultsView_Tests/test_searchResultsView_channelAvatarUpdated.1.png differ diff --git a/StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift b/StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift index 00d08588..a2fed546 100644 --- a/StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift +++ b/StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift @@ -165,7 +165,7 @@ class ViewFactory_Tests: StreamChatTestCase { let view = viewFactory.makeChannelHeaderViewModifier(for: .mockDMChannel()) // Then - XCTAssert(view is DefaultChannelHeaderModifier) + XCTAssert(view is DefaultChannelHeaderModifier) } func test_viewFactory_makeMessageTextView() { @@ -954,6 +954,20 @@ class ViewFactory_Tests: StreamChatTestCase { // Then XCTAssert(view is PollAttachmentView) } + + func test_viewFactory_makeChannelAvatarView() { + // Given + let viewFactory = DefaultViewFactory.shared + + // When + let view = viewFactory.makeChannelAvatarView( + for: .mockNonDMChannel(), + with: .init(showOnlineIndicator: false) + ) + + // Then + XCTAssert(view is ChannelAvatarView) + } } extension ChannelAction: Equatable {