Skip to content

Commit

Permalink
Customize channel avatar view (#734)
Browse files Browse the repository at this point in the history
  • Loading branch information
martinmitrevski authored Jan 29, 2025
1 parent 536ca99 commit 29091e3
Show file tree
Hide file tree
Showing 14 changed files with 175 additions and 21 deletions.
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

0 comments on commit 29091e3

Please sign in to comment.