Skip to content

Customize channel avatar view #734

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

Merged
merged 7 commits into from
Jan 29, 2025
Merged
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
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,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)
}
Expand All @@ -86,19 +92,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,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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
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,9 @@ struct SearchResultItem<ChannelDestination: View>: 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) {
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,
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)
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
11 changes: 11 additions & 0 deletions Sources/StreamChatSwiftUI/ViewFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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,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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
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.
Loading
Loading