Skip to content

Commit 5f003f8

Browse files
Redesign channel info view (#1256)
1 parent 77f0419 commit 5f003f8

File tree

188 files changed

+2561
-583
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

188 files changed

+2561
-583
lines changed

Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/AddUsersView.swift

Lines changed: 165 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -5,98 +5,205 @@
55
import StreamChat
66
import SwiftUI
77

8-
/// View for the add users popup.
8+
/// Full-sheet view for adding members to a channel.
9+
/// Supports search, pagination, and multi-select with a batch confirm action.
910
public struct AddUsersView<Factory: ViewFactory>: View {
10-
@Injected(\.fonts) private var fonts
1111
@Injected(\.colors) private var colors
1212

13-
private let columns = Array(
14-
repeating:
15-
GridItem(
16-
.adaptive(minimum: 64),
17-
alignment: .top
18-
),
19-
count: 4
20-
)
21-
22-
private let factory: Factory
13+
@Environment(\.presentationMode) private var presentationMode
2314

15+
private let factory: Factory
2416
@StateObject private var viewModel: AddUsersViewModel
25-
var onUserTap: (ChatUser) -> Void
17+
var onConfirm: @MainActor ([ChatUser]) -> Void
2618

2719
public init(
2820
factory: Factory = DefaultViewFactory.shared,
2921
loadedUserIds: [String],
30-
onUserTap: @escaping (ChatUser) -> Void
22+
onConfirm: @escaping @MainActor ([ChatUser]) -> Void
3123
) {
3224
_viewModel = StateObject(
3325
wrappedValue: AddUsersViewModel(loadedUserIds: loadedUserIds)
3426
)
35-
self.onUserTap = onUserTap
27+
self.onConfirm = onConfirm
3628
self.factory = factory
3729
}
3830

3931
init(
4032
factory: Factory = DefaultViewFactory.shared,
4133
viewModel: AddUsersViewModel,
42-
onUserTap: @escaping (ChatUser) -> Void
34+
onConfirm: @escaping @MainActor ([ChatUser]) -> Void
4335
) {
44-
_viewModel = StateObject(
45-
wrappedValue: viewModel
46-
)
47-
self.onUserTap = onUserTap
36+
_viewModel = StateObject(wrappedValue: viewModel)
37+
self.onConfirm = onConfirm
4838
self.factory = factory
4939
}
5040

5141
public var body: some View {
52-
VStack {
53-
SearchBar(text: $viewModel.searchText)
54-
55-
ScrollView {
56-
LazyVGrid(columns: columns, alignment: .center, spacing: 0) {
57-
ForEach(viewModel.users) { user in
58-
Button {
59-
onUserTap(user)
60-
} label: {
61-
VStack {
62-
let itemSize: CGFloat = 64
63-
factory.makeUserAvatarView(
64-
options: UserAvatarViewOptions(
65-
user: user,
66-
size: itemSize,
67-
showsIndicator: false
68-
)
69-
)
70-
71-
Text(user.name ?? user.id)
72-
.multilineTextAlignment(.center)
73-
.lineLimit(2)
74-
.font(fonts.footnoteBold)
75-
.frame(width: itemSize)
76-
.foregroundColor(Color(colors.text))
42+
NavigationView {
43+
VStack(spacing: 0) {
44+
SearchBar(text: $viewModel.searchText)
45+
46+
ScrollView {
47+
LazyVStack(spacing: 0) {
48+
ForEach(viewModel.users) { user in
49+
AddMembersUserRow(
50+
factory: factory,
51+
user: user,
52+
isSelected: viewModel.isSelected(user),
53+
isAlreadyMember: viewModel.isAlreadyMember(user)
54+
) {
55+
viewModel.toggleUser(user)
56+
}
57+
.onAppear {
58+
viewModel.onUserAppear(user)
7759
}
78-
.padding(.all, 8)
79-
}
80-
.onAppear {
81-
viewModel.onUserAppear(user)
8260
}
8361
}
8462
}
8563
}
86-
.frame(maxHeight: 240)
64+
.background(Color(colors.backgroundCoreApp).edgesIgnoringSafeArea(.all))
65+
.modifier(
66+
AddMembersToolbarModifier(
67+
viewModel: viewModel,
68+
onConfirm: { onConfirm(viewModel.selectedUsers) },
69+
onDismiss: { presentationMode.wrappedValue.dismiss() }
70+
)
71+
)
72+
.navigationBarTitleDisplayMode(.inline)
8773
}
88-
.standardPadding()
89-
.background(Color(colors.background))
90-
.cornerRadius(16)
91-
.padding()
9274
}
9375
}
9476

9577
/// Options used in the add users view.
9678
public final class AddUsersOptions: Sendable {
97-
public let loadedUsers: [ChatUser]
98-
99-
public init(loadedUsers: [ChatUser]) {
100-
self.loadedUsers = loadedUsers
79+
public let loadedUserIds: [String]
80+
81+
public init(loadedUserIds: [String]) {
82+
self.loadedUserIds = loadedUserIds
83+
}
84+
}
85+
86+
// MARK: - User Row
87+
88+
private struct AddMembersUserRow<Factory: ViewFactory>: View {
89+
@Injected(\.colors) private var colors
90+
@Injected(\.fonts) private var fonts
91+
@Injected(\.tokens) private var tokens
92+
@Injected(\.images) private var images
93+
94+
let factory: Factory
95+
let user: ChatUser
96+
let isSelected: Bool
97+
let isAlreadyMember: Bool
98+
let onTap: () -> Void
99+
100+
var body: some View {
101+
Button(action: { if !isAlreadyMember { onTap() } }) {
102+
HStack(spacing: tokens.spacingSm) {
103+
factory.makeUserAvatarView(
104+
options: UserAvatarViewOptions(
105+
user: user,
106+
size: AvatarSize.large,
107+
showsIndicator: false
108+
)
109+
)
110+
111+
VStack(alignment: .leading, spacing: tokens.spacingXxxs) {
112+
Text(user.name ?? user.id)
113+
.font(fonts.body)
114+
.foregroundColor(Color(colors.textPrimary))
115+
.lineLimit(1)
116+
117+
if isAlreadyMember {
118+
Text(L10n.ChatInfo.Members.alreadyMember)
119+
.font(fonts.footnote)
120+
.foregroundColor(Color(colors.textTertiary))
121+
}
122+
}
123+
124+
Spacer()
125+
126+
if !isAlreadyMember {
127+
selectionIndicator
128+
}
129+
}
130+
.padding(.horizontal, tokens.spacingMd)
131+
.padding(.vertical, tokens.spacingXs)
132+
.background(Color(colors.backgroundCoreApp))
133+
.contentShape(.rect)
134+
}
135+
.buttonStyle(.plain)
136+
}
137+
138+
private var selectionIndicator: some View {
139+
ZStack {
140+
if isSelected {
141+
Circle()
142+
.fill(Color(colors.accentPrimary))
143+
.frame(width: 24, height: 24)
144+
Image(uiImage: images.selectionBadgeIcon)
145+
.customizable()
146+
.frame(width: tokens.iconSizeXs)
147+
.foregroundColor(Color(colors.buttonPrimaryTextOnAccent))
148+
} else {
149+
Circle()
150+
.strokeBorder(Color(colors.borderCoreSubtle), lineWidth: 1.5)
151+
.frame(width: 24, height: 24)
152+
}
153+
}
154+
}
155+
}
156+
157+
// MARK: - Toolbar
158+
159+
private struct AddMembersToolbarModifier: ViewModifier {
160+
@Injected(\.colors) private var colors
161+
@Injected(\.fonts) private var fonts
162+
@Injected(\.images) private var images
163+
@Injected(\.tokens) private var tokens
164+
165+
@ObservedObject var viewModel: AddUsersViewModel
166+
let onConfirm: () -> Void
167+
let onDismiss: () -> Void
168+
169+
func body(content: Content) -> some View {
170+
if #available(iOS 26.0, *) {
171+
content
172+
.toolbarThemed {
173+
toolbarContent()
174+
#if compiler(>=6.2)
175+
.sharedBackgroundVisibility(.hidden)
176+
#endif
177+
}
178+
} else {
179+
content
180+
.toolbarThemed {
181+
toolbarContent()
182+
}
183+
}
184+
}
185+
186+
@ToolbarContentBuilder private func toolbarContent() -> some ToolbarContent {
187+
ToolbarItem(placement: .principal) {
188+
Text(L10n.ChatInfo.Members.addMembersTitle)
189+
.font(fonts.bodyBold)
190+
.foregroundColor(Color(colors.navigationBarTitle))
191+
}
192+
ToolbarItem(placement: .navigationBarLeading) {
193+
Button(action: onDismiss) {
194+
Image(uiImage: images.close)
195+
.foregroundColor(Color(colors.textSecondary))
196+
}
197+
}
198+
ToolbarItem(placement: .navigationBarTrailing) {
199+
Button(action: onConfirm) {
200+
Image(uiImage: images.selectionBadgeIcon)
201+
.customizable()
202+
.frame(width: tokens.iconSizeSm)
203+
.foregroundColor(Color(colors.buttonPrimaryTextOnAccent))
204+
}
205+
.frame(width: tokens.buttonVisualHeightMd)
206+
.background(Circle().fill(Color(colors.accentPrimary)))
207+
}
101208
}
102209
}

Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/AddUsersViewModel.swift

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,55 @@ import SwiftUI
1111
@Injected(\.chatClient) private var chatClient
1212

1313
@Published var users = [ChatUser]()
14-
@Published var searchText = "" {
15-
didSet {
16-
searchUsers(term: searchText)
17-
}
18-
}
14+
@Published var searchText = ""
15+
@Published private(set) var selectedUserIds = Set<String>()
1916

2017
private var loadedUserIds: [String]
2118
private var loadingNextUsers = false
19+
private var cancellables = Set<AnyCancellable>()
2220
private lazy var searchController: ChatUserSearchController = chatClient.userSearchController()
2321

2422
init(loadedUserIds: [String]) {
2523
self.loadedUserIds = loadedUserIds
2624
searchUsers()
25+
observeSearchText()
2726
}
2827

2928
init(loadedUserIds: [String], searchController: ChatUserSearchController) {
3029
self.loadedUserIds = loadedUserIds
3130
self.searchController = searchController
3231
searchUsers()
32+
observeSearchText()
33+
}
34+
35+
private func observeSearchText() {
36+
$searchText
37+
.dropFirst()
38+
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
39+
.sink { [weak self] term in
40+
self?.searchUsers(term: term)
41+
}
42+
.store(in: &cancellables)
43+
}
44+
45+
func toggleUser(_ user: ChatUser) {
46+
if selectedUserIds.contains(user.id) {
47+
selectedUserIds.remove(user.id)
48+
} else {
49+
selectedUserIds.insert(user.id)
50+
}
51+
}
52+
53+
func isSelected(_ user: ChatUser) -> Bool {
54+
selectedUserIds.contains(user.id)
55+
}
56+
57+
func isAlreadyMember(_ user: ChatUser) -> Bool {
58+
loadedUserIds.contains(user.id)
59+
}
60+
61+
var selectedUsers: [ChatUser] {
62+
users.filter { selectedUserIds.contains($0.id) }
3363
}
3464

3565
func onUserAppear(_ user: ChatUser) {
@@ -56,18 +86,18 @@ import SwiftUI
5686
private func searchUsers() {
5787
searchController.search(query: UserListQuery()) { [weak self] error in
5888
guard let self, error == nil else { return }
59-
users = searchController.userArray.filter { user in
60-
!self.loadedUserIds.contains(user.id)
61-
}
89+
users = searchController.userArray
6290
}
6391
}
6492

6593
private func searchUsers(term: String) {
66-
searchController.search(term: searchText) { [weak self] error in
94+
if term.isEmpty {
95+
searchUsers()
96+
return
97+
}
98+
searchController.search(term: term) { [weak self] error in
6799
guard let self, error == nil else { return }
68-
users = searchController.userArray.filter { user in
69-
!self.loadedUserIds.contains(user.id)
70-
}
100+
users = searchController.userArray
71101
}
72102
}
73103
}

0 commit comments

Comments
 (0)