Skip to content

Commit cf8b7ec

Browse files
Add option to scroll to and open a channel from the channel list (#932)
1 parent 9300e70 commit cf8b7ec

File tree

5 files changed

+181
-2
lines changed

5 files changed

+181
-2
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
33

44
# Upcoming
55

6+
### ✅ Added
7+
- Add option to scroll to and open a channel from the channel list [#932](https://github.com/GetStream/stream-chat-swiftui/pull/932)
8+
69
### 🐞 Fixed
710
- Show attachment title instead of URL in the `FileAttachmentPreview` view [#930](https://github.com/GetStream/stream-chat-swiftui/pull/930)
811
- Fix overriding title color in `ChannelTitleView` [#931](https://github.com/GetStream/stream-chat-swiftui/pull/931)

Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public struct ChannelList<Factory: ViewFactory>: View {
1414
var channels: LazyCachedMapCollection<ChatChannel>
1515
@Binding var selectedChannel: ChannelSelectionInfo?
1616
@Binding var swipedChannelId: String?
17+
@Binding var scrolledChannelId: String?
1718
private var scrollable: Bool
1819
private var onlineIndicatorShown: (ChatChannel) -> Bool
1920
private var imageLoader: (ChatChannel) -> UIImage
@@ -30,6 +31,7 @@ public struct ChannelList<Factory: ViewFactory>: View {
3031
channels: LazyCachedMapCollection<ChatChannel>,
3132
selectedChannel: Binding<ChannelSelectionInfo?>,
3233
swipedChannelId: Binding<String?>,
34+
scrolledChannelId: Binding<String?> = .constant(nil),
3335
scrollable: Bool = true,
3436
onlineIndicatorShown: ((ChatChannel) -> Bool)? = nil,
3537
imageLoader: ((ChatChannel) -> UIImage)? = nil,
@@ -72,13 +74,23 @@ public struct ChannelList<Factory: ViewFactory>: View {
7274
self.scrollable = scrollable
7375
_selectedChannel = selectedChannel
7476
_swipedChannelId = swipedChannelId
77+
_scrolledChannelId = scrolledChannelId
7578
}
7679

7780
public var body: some View {
7881
Group {
7982
if scrollable {
80-
ScrollView {
81-
channelsVStack
83+
ScrollViewReader { scrollView in
84+
ScrollView {
85+
channelsVStack
86+
}
87+
.onChange(of: scrolledChannelId) { newValue in
88+
if let newValue {
89+
withAnimation {
90+
scrollView.scrollTo(newValue, anchor: .bottom)
91+
}
92+
}
93+
}
8294
}
8395
} else {
8496
channelsVStack

Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ public struct ChatChannelListContentView<Factory: ViewFactory>: View {
234234
channels: viewModel.channels,
235235
selectedChannel: $viewModel.selectedChannel,
236236
swipedChannelId: $viewModel.swipedChannelId,
237+
scrolledChannelId: $viewModel.scrolledChannelId,
237238
onlineIndicatorShown: viewModel.onlineIndicatorShown(for:),
238239
imageLoader: channelHeaderLoader.image(for:),
239240
onItemTap: onItemTap,

Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListViewModel.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController
3939

4040
/// Index of the selected channel.
4141
private var selectedChannelIndex: Int?
42+
43+
/// When set, scrolls to the specified channel id (if it exists).
44+
@Published public var scrolledChannelId: String?
4245

4346
/// Published variables.
4447
@Published public var channels = LazyCachedMapCollection<ChatChannel>() {
@@ -180,6 +183,38 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController
180183
}
181184
}
182185
}
186+
187+
/// Opens the chat channel destination with the provided channel id.
188+
///
189+
/// - Parameter channelId: the id of the channel that will be shown.
190+
public func openChannel(with channelId: ChannelId) {
191+
func loadUntilFound() {
192+
guard let controller else { return }
193+
if let channel = controller.channels.first(where: { $0.id == channelId.rawValue }) {
194+
log.debug("Showing channel with id \(channelId)")
195+
scrollToAndOpen(channel: channel)
196+
return
197+
}
198+
199+
// Stop if there are no more channels to load
200+
if controller.hasLoadedAllPreviousChannels {
201+
scrolledChannelId = nil
202+
return
203+
}
204+
205+
controller.loadNextChannels { [weak self] error in
206+
if error != nil {
207+
self?.scrolledChannelId = nil
208+
return
209+
}
210+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
211+
loadUntilFound()
212+
}
213+
}
214+
}
215+
216+
loadUntilFound()
217+
}
183218

184219
public func loadAdditionalSearchResults(index: Int) {
185220
switch searchType {
@@ -543,6 +578,14 @@ open class ChatChannelListViewModel: ObservableObject, ChatChannelListController
543578
markDirty = true
544579
channels = LazyCachedMapCollection(source: temp, map: { $0 })
545580
}
581+
582+
private func scrollToAndOpen(channel: ChatChannel) {
583+
scrolledChannelId = channel.id
584+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
585+
self?.selectedChannel = .init(channel: channel, message: nil)
586+
self?.scrolledChannelId = nil
587+
}
588+
}
546589

547590
private func observeChannelDismiss() {
548591
NotificationCenter.default.addObserver(

StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListViewModel_Tests.swift

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,126 @@ class ChatChannelListViewModel_Tests: StreamChatTestCase {
372372
XCTAssertNotNil(viewModel.messageSearchController)
373373
}
374374

375+
// MARK: - Open Channel
376+
377+
func test_openChannel_whenChannelExistsInList_shouldScrollToAndOpenChannel() {
378+
// Given
379+
let channel = ChatChannel.mockDMChannel()
380+
let channelListController = makeChannelListController(channels: [channel])
381+
let viewModel = ChatChannelListViewModel(
382+
channelListController: channelListController,
383+
selectedChannelId: nil
384+
)
385+
386+
// When
387+
viewModel.openChannel(with: channel.cid)
388+
389+
// Then
390+
XCTAssertEqual(viewModel.scrolledChannelId, channel.id)
391+
392+
// Wait for the async delay and verify selectedChannel is set
393+
let expectation = XCTestExpectation(description: "Channel opened")
394+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
395+
XCTAssertEqual(viewModel.selectedChannel?.channel.id, channel.id)
396+
XCTAssertNil(viewModel.scrolledChannelId)
397+
expectation.fulfill()
398+
}
399+
wait(for: [expectation], timeout: 1.0)
400+
}
401+
402+
func test_openChannel_whenChannelNotInList_shouldLoadNextChannelsUntilFound() {
403+
// Given
404+
let existingChannel = ChatChannel.mockDMChannel()
405+
let targetChannel = ChatChannel.mockDMChannel()
406+
let channelListController = makeChannelListController(channels: [existingChannel])
407+
let viewModel = ChatChannelListViewModel(
408+
channelListController: channelListController,
409+
selectedChannelId: nil
410+
)
411+
412+
// When
413+
viewModel.openChannel(with: targetChannel.cid)
414+
415+
// Then
416+
XCTAssertEqual(channelListController.loadNextChannelsCallCount, 1)
417+
418+
// Simulate the channel being found after loading
419+
channelListController.simulate(
420+
channels: [existingChannel, targetChannel],
421+
changes: [.insert(targetChannel, index: .init(row: 1, section: 0))]
422+
)
423+
424+
// When
425+
viewModel.openChannel(with: targetChannel.cid)
426+
427+
// Verify the channel is eventually opened
428+
let expectation = XCTestExpectation(description: "Channel opened after loading")
429+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
430+
XCTAssertEqual(viewModel.selectedChannel?.channel.id, targetChannel.id)
431+
XCTAssertNil(viewModel.scrolledChannelId)
432+
expectation.fulfill()
433+
}
434+
wait(for: [expectation], timeout: 1.0)
435+
}
436+
437+
func test_openChannel_whenChannelNotFoundAndNoMoreChannels_shouldSetScrolledChannelIdToNil() {
438+
// Given
439+
let existingChannel = ChatChannel.mockDMChannel()
440+
let targetChannel = ChatChannel.mockDMChannel()
441+
let channelListController = makeChannelListController(channels: [existingChannel])
442+
let viewModel = ChatChannelListViewModel(
443+
channelListController: channelListController,
444+
selectedChannelId: nil
445+
)
446+
447+
// When
448+
viewModel.openChannel(with: targetChannel.cid)
449+
450+
// Then
451+
XCTAssertNil(viewModel.scrolledChannelId)
452+
XCTAssertNil(viewModel.selectedChannel)
453+
}
454+
455+
func test_openChannel_whenChannelFoundAfterMultipleLoads_shouldEventuallyOpenChannel() {
456+
// Given
457+
let existingChannel = ChatChannel.mockDMChannel()
458+
let targetChannel = ChatChannel.mockDMChannel()
459+
let channelListController = makeChannelListController(channels: [existingChannel])
460+
let viewModel = ChatChannelListViewModel(
461+
channelListController: channelListController,
462+
selectedChannelId: nil
463+
)
464+
465+
// When
466+
viewModel.openChannel(with: targetChannel.cid)
467+
468+
// Then
469+
XCTAssertEqual(channelListController.loadNextChannelsCallCount, 1)
470+
471+
// Simulate first load not finding the channel
472+
channelListController.simulate(
473+
channels: [existingChannel],
474+
changes: []
475+
)
476+
477+
// Simulate second load finding the channel
478+
channelListController.simulate(
479+
channels: [existingChannel, targetChannel],
480+
changes: [.insert(targetChannel, index: .init(row: 1, section: 0))]
481+
)
482+
483+
viewModel.openChannel(with: targetChannel.cid)
484+
485+
// Verify the channel is eventually opened
486+
let expectation = XCTestExpectation(description: "Channel opened after multiple loads")
487+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
488+
XCTAssertEqual(viewModel.selectedChannel?.channel.id, targetChannel.id)
489+
XCTAssertNil(viewModel.scrolledChannelId)
490+
expectation.fulfill()
491+
}
492+
wait(for: [expectation], timeout: 1.0)
493+
}
494+
375495
// MARK: - private
376496

377497
private func makeChannelListController(

0 commit comments

Comments
 (0)