Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Customize channel avatar view #734

Merged
merged 7 commits into from
Jan 29, 2025
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public protocol ChatChannelHeaderViewModifier: ViewModifier {
}

/// The default channel header.
public struct DefaultChatChannelHeader: ToolbarContent {
public struct DefaultChatChannelHeader<Factory: ViewFactory>: ToolbarContent {
@Injected(\.fonts) private var fonts
@Injected(\.utils) private var utils
@Injected(\.colors) private var colors
Expand All @@ -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<Bool>
) {
self.factory = factory
self.channel = channel
self.headerImage = headerImage
_isActive = isActive
Expand All @@ -64,8 +67,8 @@ public struct DefaultChatChannelHeader: ToolbarContent {
resignFirstResponder()
isActive = true
} label: {
ChannelAvatarView(
avatar: headerImage,
factory.makeChannelAvatarView(
for: channel,
showOnlineIndicator: onlineIndicatorShown,
size: CGSize(width: 36, height: 36)
)
Expand All @@ -86,19 +89,25 @@ public struct DefaultChatChannelHeader: ToolbarContent {
}

/// The default header modifier.
public struct DefaultChannelHeaderModifier: ChatChannelHeaderViewModifier {
public struct DefaultChannelHeaderModifier<Factory: ViewFactory>: 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ import StreamChat
import SwiftUI

/// View for the channel list item.
public struct ChatChannelListItem: View {
public struct ChatChannelListItem<Factory: ViewFactory>: View {

@Injected(\.fonts) private var fonts
@Injected(\.colors) private var colors
@Injected(\.utils) private var utils
@Injected(\.images) private var images
@Injected(\.chatClient) private var chatClient

var factory: Factory
var channel: ChatChannel
var channelName: String
var injectedChannelInfo: InjectedChannelInfo?
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -45,9 +48,10 @@ public struct ChatChannelListItem: View {
onItemTap(channel)
} label: {
HStack {
ChannelAvatarView(
channel: channel,
showOnlineIndicator: onlineIndicatorShown
factory.makeChannelAvatarView(
for: channel,
showOnlineIndicator: onlineIndicatorShown,
size: .defaultAvatarSize
)

VStack(alignment: .leading, spacing: 4) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChannelDestination: View>: View {
public struct ChatChannelNavigatableListItem<Factory: ViewFactory, ChannelDestination: View>: View {
private var factory: Factory
private var channel: ChatChannel
private var channelName: String
private var avatar: UIImage
Expand All @@ -18,6 +19,7 @@ public struct ChatChannelNavigatableListItem<ChannelDestination: View>: View {
private var onItemTap: (ChatChannel) -> Void

public init(
factory: Factory = DefaultViewFactory.shared,
channel: ChatChannel,
channelName: String,
avatar: UIImage,
Expand All @@ -27,6 +29,7 @@ public struct ChatChannelNavigatableListItem<ChannelDestination: View>: View {
channelDestination: @escaping (ChannelSelectionInfo) -> ChannelDestination,
onItemTap: @escaping (ChatChannel) -> Void
) {
self.factory = factory
self.channel = channel
self.channelName = channelName
self.channelDestination = channelDestination
Expand All @@ -40,6 +43,7 @@ public struct ChatChannelNavigatableListItem<ChannelDestination: View>: View {
public var body: some View {
ZStack {
ChatChannelListItem(
factory: factory,
channel: channel,
channelName: channelName,
injectedChannelInfo: injectedChannelInfo,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,11 @@ struct SearchResultView<Factory: ViewFactory>: View {
}

/// The search result item user interface.
struct SearchResultItem<ChannelDestination: View>: View {
struct SearchResultItem<Factory: ViewFactory, ChannelDestination: View>: View {

@Injected(\.utils) private var utils

var factory: Factory
var searchResult: ChannelSelectionInfo
var onlineIndicatorShown: Bool
var channelName: String
Expand All @@ -134,9 +135,10 @@ struct SearchResultItem<ChannelDestination: View>: View {
onSearchResultTap(searchResult)
} label: {
HStack {
ChannelAvatarView(
avatar: avatar,
showOnlineIndicator: onlineIndicatorShown
factory.makeChannelAvatarView(
for: searchResult.channel,
showOnlineIndicator: onlineIndicatorShown,
size: .defaultAvatarSize
)

VStack(alignment: .leading, spacing: 4) {
Expand Down
16 changes: 15 additions & 1 deletion Sources/StreamChatSwiftUI/DefaultViewFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ extension ViewFactory {
leadingSwipeButtonTapped: @escaping (ChatChannel) -> Void
) -> some View {
let listItem = ChatChannelNavigatableListItem(
factory: self,
channel: channel,
channelName: channelName,
avatar: avatar,
Expand All @@ -95,6 +96,18 @@ extension ViewFactory {
)
}

public func makeChannelAvatarView(
for channel: ChatChannel,
showOnlineIndicator: Bool,
size: CGSize
) -> some View {
ChannelAvatarView(
channel: channel,
showOnlineIndicator: showOnlineIndicator,
size: size
)
}

public func makeChannelListBackground(colors: ColorPalette) -> some View {
Color(colors.background)
.edgesIgnoringSafeArea(.bottom)
Expand Down Expand Up @@ -189,6 +202,7 @@ extension ViewFactory {
channelDestination: @escaping (ChannelSelectionInfo) -> ChannelDestination
) -> some View {
SearchResultItem(
factory: self,
searchResult: searchResult,
onlineIndicatorShown: onlineIndicatorShown,
channelName: channelName,
Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions Sources/StreamChatSwiftUI/ViewFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@ 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.
/// - showOnlineIndicator: whether the online indicator is shown.
/// - size: the size of the avatar.
/// - Returns: view displayed in the channel avatar slot.
func makeChannelAvatarView(
for channel: ChatChannel,
showOnlineIndicator: Bool,
size: CGSize
) -> ChannelAvatarViewType

associatedtype ChannelListBackground: View
/// Creates the background for the channel list.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,27 @@
// 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
Expand All @@ -42,7 +63,7 @@
.applyDefaultSize()

// Then
assertSnapshot(matching: view, as: .image(perceptualPrecision: precision))

Check failure on line 66 in StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelHeader_Tests.swift

View workflow job for this annotation

GitHub Actions / Test SwiftUI (Debug)

test_chatChannelHeader_snapshot, failed - No reference was found on disk. Automatically recorded snapshot: …

Check failure on line 66 in StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelHeader_Tests.swift

View workflow job for this annotation

GitHub Actions / Test SwiftUI (Debug)

test_chatChannelHeader_snapshot, failed - Snapshot does not match reference.

Check failure on line 66 in StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelHeader_Tests.swift

View workflow job for this annotation

GitHub Actions / Test SwiftUI (Debug)

test_chatChannelHeader_snapshot, failed - Snapshot does not match reference.
}

func test_channelTitleView_snapshot() {
Expand All @@ -54,6 +75,6 @@
.applyDefaultSize()

// Then
assertSnapshot(matching: view, as: .image(perceptualPrecision: precision))

Check failure on line 78 in StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelHeader_Tests.swift

View workflow job for this annotation

GitHub Actions / Test SwiftUI (Debug)

test_channelTitleView_snapshot, failed - No reference was found on disk. Automatically recorded snapshot: …
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -87,3 +102,18 @@ class ChatChannelListView_Tests: StreamChatTestCase {
return channels
}
}

class ChannelAvatarViewFactory: ViewFactory {

@Injected(\.chatClient) var chatClient

func makeChannelAvatarView(
for channel: ChatChannel,
showOnlineIndicator: Bool,
size: CGSize
) -> some View {
Circle()
.fill(.red)
.frame(width: size.width, height: size.height)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
.applyDefaultSize()

// Then
assertSnapshot(matching: view, as: .image)

Check failure on line 53 in StreamChatSwiftUITests/Tests/ChatChannelList/SearchResultsView_Tests.swift

View workflow job for this annotation

GitHub Actions / Test SwiftUI (Debug)

test_searchResultsView_snapshotResults, failed - Snapshot does not match reference.

Check failure on line 53 in StreamChatSwiftUITests/Tests/ChatChannelList/SearchResultsView_Tests.swift

View workflow job for this annotation

GitHub Actions / Test SwiftUI (Debug)

test_searchResultsView_snapshotResults, failed - Snapshot does not match reference.
}

func test_searchResultsView_snapshotResults_whenChannelSearch() {
Expand Down Expand Up @@ -96,7 +96,7 @@
.applyDefaultSize()

// Then
assertSnapshot(matching: view, as: .image)

Check failure on line 99 in StreamChatSwiftUITests/Tests/ChatChannelList/SearchResultsView_Tests.swift

View workflow job for this annotation

GitHub Actions / Test SwiftUI (Debug)

test_searchResultsView_snapshotResults_whenChannelSearch, failed - Snapshot does not match reference.

Check failure on line 99 in StreamChatSwiftUITests/Tests/ChatChannelList/SearchResultsView_Tests.swift

View workflow job for this annotation

GitHub Actions / Test SwiftUI (Debug)

test_searchResultsView_snapshotResults_whenChannelSearch, failed - Snapshot does not match reference.
}

func test_searchResultsView_snapshotNoResults() {
Expand Down Expand Up @@ -142,4 +142,37 @@
// 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)
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 16 additions & 1 deletion StreamChatSwiftUITests/Tests/Utils/ViewFactory_Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ class ViewFactory_Tests: StreamChatTestCase {
let view = viewFactory.makeChannelHeaderViewModifier(for: .mockDMChannel())

// Then
XCTAssert(view is DefaultChannelHeaderModifier)
XCTAssert(view is DefaultChannelHeaderModifier<DefaultViewFactory>)
}

func test_viewFactory_makeMessageTextView() {
Expand Down Expand Up @@ -954,6 +954,21 @@ class ViewFactory_Tests: StreamChatTestCase {
// Then
XCTAssert(view is PollAttachmentView<DefaultViewFactory>)
}

func test_viewFactory_makeChannelAvatarView() {
// Given
let viewFactory = DefaultViewFactory.shared

// When
let view = viewFactory.makeChannelAvatarView(
for: .mockNonDMChannel(),
showOnlineIndicator: false,
size: .defaultAvatarSize
)

// Then
XCTAssert(view is ChannelAvatarView)
}
}

extension ChannelAction: Equatable {
Expand Down
Loading