Skip to content

Commit 7df1f28

Browse files
Bidirectional list scrolling (#404)
1 parent 862d162 commit 7df1f28

16 files changed

+366
-44
lines changed

CHANGELOG.md

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

44
# Upcoming
55

6+
### ✅ Added
7+
- Jump to a message that is not on the first page
8+
- Jump to a message in a thread
9+
- Bi-directional scrolling of the message list
10+
611
### 🐞 Fixed
712
- Some links not being rendered correctly
813

Sources/StreamChatSwiftUI/ChatChannel/ChatChannelDataSource.swift

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ protocol ChannelDataSource: AnyObject {
3838

3939
/// List of the messages.
4040
var messages: LazyCachedMapCollection<ChatMessage> { get }
41+
42+
/// Determines whether all new messages have been fetched.
43+
var hasLoadedAllNextMessages: Bool { get }
4144

4245
/// Loads the previous messages.
4346
/// - Parameters:
@@ -49,16 +52,43 @@ protocol ChannelDataSource: AnyObject {
4952
limit: Int,
5053
completion: ((Error?) -> Void)?
5154
)
55+
56+
/// Loads newer messages.
57+
/// - Parameters:
58+
/// - limit: the max number of messages to be retrieved.
59+
/// - completion: called when the messages are loaded.
60+
func loadNextMessages(
61+
limit: Int,
62+
completion: ((Error?) -> Void)?
63+
)
64+
65+
/// Loads a page around the provided message id.
66+
/// - Parameters:
67+
/// - messageId: the id of the message.
68+
/// - completion: called when the messages are loaded.
69+
func loadPageAroundMessageId(
70+
_ messageId: MessageId,
71+
completion: ((Error?) -> Void)?
72+
)
73+
74+
/// Loads the first page of the channel.
75+
/// - Parameter completion: called when the initial page is loaded.
76+
func loadFirstPage(_ completion: ((_ error: Error?) -> Void)?)
5277
}
5378

5479
/// Implementation of `ChannelDataSource`. Loads the messages of the channel.
5580
class ChatChannelDataSource: ChannelDataSource, ChatChannelControllerDelegate {
5681

5782
let controller: ChatChannelController
5883
weak var delegate: MessagesDataSource?
84+
5985
var messages: LazyCachedMapCollection<ChatMessage> {
6086
controller.messages
6187
}
88+
89+
var hasLoadedAllNextMessages: Bool {
90+
controller.hasLoadedAllNextMessages
91+
}
6292

6393
init(controller: ChatChannelController) {
6494
self.controller = controller
@@ -98,17 +128,38 @@ class ChatChannelDataSource: ChannelDataSource, ChatChannelControllerDelegate {
98128
completion: completion
99129
)
100130
}
131+
132+
func loadNextMessages(limit: Int, completion: ((Error?) -> Void)?) {
133+
controller.loadNextMessages(limit: limit, completion: completion)
134+
}
135+
136+
func loadPageAroundMessageId(
137+
_ messageId: MessageId,
138+
completion: ((Error?) -> Void)?
139+
) {
140+
controller.loadPageAroundMessageId(messageId, completion: completion)
141+
}
142+
143+
func loadFirstPage(_ completion: ((_ error: Error?) -> Void)?) {
144+
controller.loadFirstPage(completion)
145+
}
101146
}
102147

103148
/// Implementation of the `ChannelDataSource`. Loads the messages in a reply thread.
104149
class MessageThreadDataSource: ChannelDataSource, ChatMessageControllerDelegate {
105150

106151
let channelController: ChatChannelController
107152
let messageController: ChatMessageController
153+
108154
weak var delegate: MessagesDataSource?
155+
109156
var messages: LazyCachedMapCollection<ChatMessage> {
110157
messageController.replies
111158
}
159+
160+
var hasLoadedAllNextMessages: Bool {
161+
messageController.hasLoadedAllNextReplies
162+
}
112163

113164
init(
114165
channelController: ChatChannelController,
@@ -160,4 +211,19 @@ class MessageThreadDataSource: ChannelDataSource, ChatMessageControllerDelegate
160211
completion: completion
161212
)
162213
}
214+
215+
func loadNextMessages(limit: Int, completion: ((Error?) -> Void)?) {
216+
messageController.loadNextReplies(limit: limit, completion: completion)
217+
}
218+
219+
func loadPageAroundMessageId(
220+
_ messageId: MessageId,
221+
completion: ((Error?) -> Void)?
222+
) {
223+
messageController.loadPageAroundReplyId(messageId, completion: completion)
224+
}
225+
226+
func loadFirstPage(_ completion: ((_ error: Error?) -> Void)?) {
227+
messageController.loadFirstPage(completion)
228+
}
163229
}

Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,17 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
5555
listId: viewModel.listId,
5656
isMessageThread: viewModel.isMessageThread,
5757
shouldShowTypingIndicator: viewModel.shouldShowTypingIndicator,
58-
onMessageAppear: viewModel.handleMessageAppear(index:),
58+
scrollPosition: $viewModel.scrollPosition,
59+
loadingNextMessages: viewModel.loadingNextMessages,
60+
onMessageAppear: viewModel.handleMessageAppear(index:scrollDirection:),
5961
onScrollToBottom: viewModel.scrollToLastMessage,
6062
onLongPress: { displayInfo in
6163
messageDisplayInfo = displayInfo
6264
withAnimation {
6365
viewModel.showReactionOverlay(for: AnyView(self))
6466
}
65-
}
67+
},
68+
onJumpToMessage: viewModel.jumpToMessage(messageId:)
6669
)
6770
.overlay(
6871
viewModel.currentDateString != nil ?

Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift

Lines changed: 114 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
1717
private var cancellables = Set<AnyCancellable>()
1818
private var lastRefreshThreshold = 200
1919
private let refreshThreshold = 200
20+
private let newerMessagesLimit: Int = {
21+
if #available(iOS 17, *) {
22+
// On iOS 17 we can maintain the scroll position.
23+
return 25
24+
} else {
25+
return 5
26+
}
27+
}()
28+
2029
private var timer: Timer?
2130
private var currentDate: Date? {
2231
didSet {
@@ -33,6 +42,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
3342
private lazy var messageCachingUtils = utils.messageCachingUtils
3443

3544
private var loadingPreviousMessages: Bool = false
45+
private var loadingMessagesAround: Bool = false
3646
private var lastMessageRead: String?
3747
private var disableDateIndicator = false
3848
private var channelName = ""
@@ -96,6 +106,8 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
96106
}
97107

98108
@Published public var shouldShowTypingIndicator = false
109+
@Published public var scrollPosition: String?
110+
@Published public private(set) var loadingNextMessages: Bool = false
99111

100112
public var channel: ChatChannel? {
101113
channelController.channel
@@ -129,7 +141,17 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
129141
messages = channelDataSource.messages
130142

131143
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
132-
self?.scrolledId = scrollToMessage?.messageId
144+
if let scrollToMessage, let parentMessageId = scrollToMessage.parentMessageId, messageController == nil {
145+
let message = channelController.dataStore.message(id: parentMessageId)
146+
self?.threadMessage = message
147+
self?.threadMessageShown = true
148+
self?.messageCachingUtils.jumpToReplyId = scrollToMessage.messageId
149+
} else if messageController != nil, let jumpToReplyId = self?.messageCachingUtils.jumpToReplyId {
150+
self?.scrolledId = jumpToReplyId
151+
self?.messageCachingUtils.jumpToReplyId = nil
152+
} else if messageController == nil {
153+
self?.scrolledId = scrollToMessage?.messageId
154+
}
133155
}
134156

135157
NotificationCenter.default.addObserver(
@@ -180,19 +202,71 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
180202
}
181203

182204
public func scrollToLastMessage() {
183-
if scrolledId != nil {
205+
if channelDataSource.hasLoadedAllNextMessages {
206+
updateScrolledIdToNewestMessage()
207+
} else {
208+
channelDataSource.loadFirstPage { [weak self] _ in
209+
self?.scrolledId = self?.messages.first?.messageId
210+
}
211+
}
212+
}
213+
214+
public func jumpToMessage(messageId: String) -> Bool {
215+
if messageId == messages.first?.messageId {
184216
scrolledId = nil
217+
return true
218+
} else {
219+
guard let baseId = messageId.components(separatedBy: "$").first else {
220+
scrolledId = nil
221+
return true
222+
}
223+
let alreadyLoaded = messages.map(\.id).contains(baseId)
224+
if alreadyLoaded {
225+
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
226+
self.scrolledId = nil
227+
}
228+
return true
229+
} else {
230+
let message = channelController.dataStore.message(id: baseId)
231+
if let parentMessageId = message?.parentMessageId, !isMessageThread {
232+
let parentMessage = channelController.dataStore.message(id: parentMessageId)
233+
threadMessage = parentMessage
234+
threadMessageShown = true
235+
messageCachingUtils.jumpToReplyId = message?.messageId
236+
return false
237+
}
238+
239+
scrolledId = nil
240+
if loadingMessagesAround {
241+
return false
242+
}
243+
loadingMessagesAround = true
244+
channelDataSource.loadPageAroundMessageId(baseId) { [weak self] error in
245+
if error != nil {
246+
log.error("Error loading messages around message \(messageId)")
247+
return
248+
}
249+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
250+
self?.scrolledId = messageId
251+
self?.loadingMessagesAround = false
252+
}
253+
}
254+
return false
255+
}
185256
}
186-
scrolledId = messages.first?.messageId
187257
}
188258

189-
open func handleMessageAppear(index: Int) {
190-
if index >= messages.count {
259+
open func handleMessageAppear(index: Int, scrollDirection: ScrollDirection) {
260+
if index >= channelDataSource.messages.count || loadingMessagesAround {
191261
return
192262
}
193263

194264
let message = messages[index]
195-
checkForNewMessages(index: index)
265+
if scrollDirection == .up {
266+
checkForOlderMessages(index: index)
267+
} else {
268+
checkForNewerMessages(index: index)
269+
}
196270
if utils.messageListConfig.dateIndicatorPlacement == .overlay {
197271
save(lastDate: message.createdAt)
198272
}
@@ -278,8 +352,8 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
278352

279353
maybeRefreshMessageList()
280354

281-
if !showScrollToLatestButton && scrolledId == nil {
282-
scrollToLastMessage()
355+
if !showScrollToLatestButton && scrolledId == nil && !loadingNextMessages {
356+
updateScrolledIdToNewestMessage()
283357
}
284358
}
285359

@@ -321,11 +395,12 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
321395

322396
// MARK: - private
323397

324-
private func checkForNewMessages(index: Int) {
398+
private func checkForOlderMessages(index: Int) {
325399
if index < channelDataSource.messages.count - 25 {
326400
return
327401
}
328402

403+
log.debug("Loading previous messages")
329404
if !loadingPreviousMessages {
330405
loadingPreviousMessages = true
331406
channelDataSource.loadPreviousMessages(
@@ -338,6 +413,27 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
338413
)
339414
}
340415
}
416+
417+
private func checkForNewerMessages(index: Int) {
418+
if channelDataSource.hasLoadedAllNextMessages {
419+
return
420+
}
421+
if loadingNextMessages || (index > 5) {
422+
return
423+
}
424+
loadingNextMessages = true
425+
426+
if scrollPosition != messages.first?.messageId {
427+
scrollPosition = messages[index].messageId
428+
}
429+
430+
channelDataSource.loadNextMessages(limit: newerMessagesLimit) { [weak self] _ in
431+
guard let self = self else { return }
432+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
433+
self.loadingNextMessages = false
434+
}
435+
}
436+
}
341437

342438
private func save(lastDate: Date) {
343439
if disableDateIndicator {
@@ -469,7 +565,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
469565
}
470566

471567
private func shouldAnimate(changes: [ListChange<ChatMessage>]) -> AnimationChange {
472-
if !utils.messageListConfig.messageDisplayOptions.animateChanges {
568+
if !utils.messageListConfig.messageDisplayOptions.animateChanges || loadingNextMessages {
473569
return .notAnimated
474570
}
475571

@@ -522,6 +618,13 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
522618
}
523619
}
524620

621+
private func updateScrolledIdToNewestMessage() {
622+
if scrolledId != nil {
623+
scrolledId = nil
624+
}
625+
scrolledId = messages.first?.messageId
626+
}
627+
525628
deinit {
526629
messageCachingUtils.clearCache()
527630
if messageController == nil {
@@ -542,7 +645,7 @@ extension ChatMessage: Identifiable {
542645
}
543646

544647
var baseId: String {
545-
isDeleted ? "\(id)-deleted" : id
648+
isDeleted ? "\(id)$deleted" : "\(id)$"
546649
}
547650

548651
var pinStateId: String {

Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public struct MessageListConfig {
1515
messageDisplayOptions: MessageDisplayOptions = MessageDisplayOptions(),
1616
messagePaddings: MessagePaddings = MessagePaddings(),
1717
dateIndicatorPlacement: DateIndicatorPlacement = .overlay,
18-
pageSize: Int = 50,
18+
pageSize: Int = 25,
1919
messagePopoverEnabled: Bool = true,
2020
doubleTapOverlayEnabled: Bool = false,
2121
becomesFirstResponderOnOpen: Bool = false,

0 commit comments

Comments
 (0)