Skip to content
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

Bidirectional list scrolling #404

Merged
merged 23 commits into from
Nov 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f345d88
Initial implementation of bi-directional scrolling
martinmitrevski Nov 23, 2022
024d0fe
Updates to the initial implementation
martinmitrevski Nov 24, 2022
1107b75
Small updates
martinmitrevski Nov 29, 2022
052a338
Merged develop
martinmitrevski Jan 20, 2023
48e1b50
Improvements to the scrolling experience
martinmitrevski Jan 20, 2023
65bf4bc
Merged main
martinmitrevski Feb 15, 2023
0513922
Merged latest main
martinmitrevski Feb 24, 2023
33517a5
Merge branch 'main' of https://github.com/GetStream/stream-chat-swift…
martinmitrevski Feb 27, 2023
b638b77
Merged main
martinmitrevski Mar 23, 2023
bc5a935
Merged main
martinmitrevski Nov 16, 2023
288133b
Improved scrolling
martinmitrevski Nov 17, 2023
2c9c2a8
Merge branch 'main' of https://github.com/GetStream/stream-chat-swift…
martinmitrevski Nov 20, 2023
480b947
Merge branch 'main' of https://github.com/GetStream/stream-chat-swift…
martinmitrevski Nov 22, 2023
9935eaf
Scrolling improvements
martinmitrevski Nov 22, 2023
053ea65
Small fixes
martinmitrevski Nov 22, 2023
00ced84
Reverted the base id
martinmitrevski Nov 22, 2023
ca4d3f7
Fixed tests
martinmitrevski Nov 22, 2023
6575128
Fixed bug with disappearing messages
martinmitrevski Nov 23, 2023
7c2ddc7
Implemented jumping to threads
martinmitrevski Nov 24, 2023
d1a163b
Fixed jumping to quoted messages in threads
martinmitrevski Nov 24, 2023
b2a68d9
Fixed scrolled to bottom button
martinmitrevski Nov 24, 2023
ab926e6
Small updates
martinmitrevski Nov 26, 2023
11cd0f8
Added tests
martinmitrevski Nov 27, 2023
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

### ✅ Added
- Jump to a message that is not on the first page
- Jump to a message in a thread
- Bi-directional scrolling of the message list

### 🐞 Fixed
- Some links not being rendered correctly

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ protocol ChannelDataSource: AnyObject {

/// List of the messages.
var messages: LazyCachedMapCollection<ChatMessage> { get }

/// Determines whether all new messages have been fetched.
var hasLoadedAllNextMessages: Bool { get }

/// Loads the previous messages.
/// - Parameters:
Expand All @@ -49,16 +52,43 @@ protocol ChannelDataSource: AnyObject {
limit: Int,
completion: ((Error?) -> Void)?
)

/// Loads newer messages.
/// - Parameters:
/// - limit: the max number of messages to be retrieved.
/// - completion: called when the messages are loaded.
func loadNextMessages(
limit: Int,
completion: ((Error?) -> Void)?
)

/// Loads a page around the provided message id.
/// - Parameters:
/// - messageId: the id of the message.
/// - completion: called when the messages are loaded.
func loadPageAroundMessageId(
_ messageId: MessageId,
completion: ((Error?) -> Void)?
)

/// Loads the first page of the channel.
/// - Parameter completion: called when the initial page is loaded.
func loadFirstPage(_ completion: ((_ error: Error?) -> Void)?)
}

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

let controller: ChatChannelController
weak var delegate: MessagesDataSource?

var messages: LazyCachedMapCollection<ChatMessage> {
controller.messages
}

var hasLoadedAllNextMessages: Bool {
controller.hasLoadedAllNextMessages
}

init(controller: ChatChannelController) {
self.controller = controller
Expand Down Expand Up @@ -98,17 +128,38 @@ class ChatChannelDataSource: ChannelDataSource, ChatChannelControllerDelegate {
completion: completion
)
}

func loadNextMessages(limit: Int, completion: ((Error?) -> Void)?) {
controller.loadNextMessages(limit: limit, completion: completion)
}

func loadPageAroundMessageId(
_ messageId: MessageId,
completion: ((Error?) -> Void)?
) {
controller.loadPageAroundMessageId(messageId, completion: completion)
}

func loadFirstPage(_ completion: ((_ error: Error?) -> Void)?) {
controller.loadFirstPage(completion)
}
}

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

let channelController: ChatChannelController
let messageController: ChatMessageController

weak var delegate: MessagesDataSource?

var messages: LazyCachedMapCollection<ChatMessage> {
messageController.replies
}

var hasLoadedAllNextMessages: Bool {
messageController.hasLoadedAllNextReplies
}

init(
channelController: ChatChannelController,
Expand Down Expand Up @@ -160,4 +211,19 @@ class MessageThreadDataSource: ChannelDataSource, ChatMessageControllerDelegate
completion: completion
)
}

func loadNextMessages(limit: Int, completion: ((Error?) -> Void)?) {
messageController.loadNextReplies(limit: limit, completion: completion)
}

func loadPageAroundMessageId(
_ messageId: MessageId,
completion: ((Error?) -> Void)?
) {
messageController.loadPageAroundReplyId(messageId, completion: completion)
}

func loadFirstPage(_ completion: ((_ error: Error?) -> Void)?) {
messageController.loadFirstPage(completion)
}
}
7 changes: 5 additions & 2 deletions Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,17 @@ public struct ChatChannelView<Factory: ViewFactory>: View, KeyboardReadable {
listId: viewModel.listId,
isMessageThread: viewModel.isMessageThread,
shouldShowTypingIndicator: viewModel.shouldShowTypingIndicator,
onMessageAppear: viewModel.handleMessageAppear(index:),
scrollPosition: $viewModel.scrollPosition,
loadingNextMessages: viewModel.loadingNextMessages,
onMessageAppear: viewModel.handleMessageAppear(index:scrollDirection:),
onScrollToBottom: viewModel.scrollToLastMessage,
onLongPress: { displayInfo in
messageDisplayInfo = displayInfo
withAnimation {
viewModel.showReactionOverlay(for: AnyView(self))
}
}
},
onJumpToMessage: viewModel.jumpToMessage(messageId:)
)
.overlay(
viewModel.currentDateString != nil ?
Expand Down
125 changes: 114 additions & 11 deletions Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
private var cancellables = Set<AnyCancellable>()
private var lastRefreshThreshold = 200
private let refreshThreshold = 200
private let newerMessagesLimit: Int = {
if #available(iOS 17, *) {
// On iOS 17 we can maintain the scroll position.
return 25
} else {
return 5
}
}()

private var timer: Timer?
private var currentDate: Date? {
didSet {
Expand All @@ -33,6 +42,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
private lazy var messageCachingUtils = utils.messageCachingUtils

private var loadingPreviousMessages: Bool = false
private var loadingMessagesAround: Bool = false
private var lastMessageRead: String?
private var disableDateIndicator = false
private var channelName = ""
Expand Down Expand Up @@ -96,6 +106,8 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
}

@Published public var shouldShowTypingIndicator = false
@Published public var scrollPosition: String?
@Published public private(set) var loadingNextMessages: Bool = false

public var channel: ChatChannel? {
channelController.channel
Expand Down Expand Up @@ -129,7 +141,17 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
messages = channelDataSource.messages

DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.scrolledId = scrollToMessage?.messageId
if let scrollToMessage, let parentMessageId = scrollToMessage.parentMessageId, messageController == nil {
let message = channelController.dataStore.message(id: parentMessageId)
self?.threadMessage = message
self?.threadMessageShown = true
self?.messageCachingUtils.jumpToReplyId = scrollToMessage.messageId
} else if messageController != nil, let jumpToReplyId = self?.messageCachingUtils.jumpToReplyId {
self?.scrolledId = jumpToReplyId
self?.messageCachingUtils.jumpToReplyId = nil
} else if messageController == nil {
self?.scrolledId = scrollToMessage?.messageId
}
}

NotificationCenter.default.addObserver(
Expand Down Expand Up @@ -180,19 +202,71 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
}

public func scrollToLastMessage() {
if scrolledId != nil {
if channelDataSource.hasLoadedAllNextMessages {
updateScrolledIdToNewestMessage()
} else {
channelDataSource.loadFirstPage { [weak self] _ in
self?.scrolledId = self?.messages.first?.messageId
}
}
}

public func jumpToMessage(messageId: String) -> Bool {
if messageId == messages.first?.messageId {
scrolledId = nil
return true
} else {
guard let baseId = messageId.components(separatedBy: "$").first else {
scrolledId = nil
return true
}
let alreadyLoaded = messages.map(\.id).contains(baseId)
if alreadyLoaded {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.scrolledId = nil
}
return true
} else {
let message = channelController.dataStore.message(id: baseId)
if let parentMessageId = message?.parentMessageId, !isMessageThread {
let parentMessage = channelController.dataStore.message(id: parentMessageId)
threadMessage = parentMessage
threadMessageShown = true
messageCachingUtils.jumpToReplyId = message?.messageId
return false
}

scrolledId = nil
if loadingMessagesAround {
return false
}
loadingMessagesAround = true
channelDataSource.loadPageAroundMessageId(baseId) { [weak self] error in
if error != nil {
log.error("Error loading messages around message \(messageId)")
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self?.scrolledId = messageId
self?.loadingMessagesAround = false
}
}
return false
}
}
scrolledId = messages.first?.messageId
}

open func handleMessageAppear(index: Int) {
if index >= messages.count {
open func handleMessageAppear(index: Int, scrollDirection: ScrollDirection) {
if index >= channelDataSource.messages.count || loadingMessagesAround {
return
}

let message = messages[index]
checkForNewMessages(index: index)
if scrollDirection == .up {
checkForOlderMessages(index: index)
} else {
checkForNewerMessages(index: index)
}
if utils.messageListConfig.dateIndicatorPlacement == .overlay {
save(lastDate: message.createdAt)
}
Expand Down Expand Up @@ -278,8 +352,8 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {

maybeRefreshMessageList()

if !showScrollToLatestButton && scrolledId == nil {
scrollToLastMessage()
if !showScrollToLatestButton && scrolledId == nil && !loadingNextMessages {
updateScrolledIdToNewestMessage()
}
}

Expand Down Expand Up @@ -321,11 +395,12 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {

// MARK: - private

private func checkForNewMessages(index: Int) {
private func checkForOlderMessages(index: Int) {
if index < channelDataSource.messages.count - 25 {
return
}

log.debug("Loading previous messages")
if !loadingPreviousMessages {
loadingPreviousMessages = true
channelDataSource.loadPreviousMessages(
Expand All @@ -338,6 +413,27 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
)
}
}

private func checkForNewerMessages(index: Int) {
if channelDataSource.hasLoadedAllNextMessages {
return
}
if loadingNextMessages || (index > 5) {
return
}
loadingNextMessages = true

if scrollPosition != messages.first?.messageId {
scrollPosition = messages[index].messageId
}

channelDataSource.loadNextMessages(limit: newerMessagesLimit) { [weak self] _ in
guard let self = self else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.loadingNextMessages = false
}
}
}

private func save(lastDate: Date) {
if disableDateIndicator {
Expand Down Expand Up @@ -469,7 +565,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
}

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

Expand Down Expand Up @@ -522,6 +618,13 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
}
}

private func updateScrolledIdToNewestMessage() {
if scrolledId != nil {
scrolledId = nil
}
scrolledId = messages.first?.messageId
}

deinit {
messageCachingUtils.clearCache()
if messageController == nil {
Expand All @@ -542,7 +645,7 @@ extension ChatMessage: Identifiable {
}

var baseId: String {
isDeleted ? "\(id)-deleted" : id
isDeleted ? "\(id)$deleted" : "\(id)$"
}

var pinStateId: String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public struct MessageListConfig {
messageDisplayOptions: MessageDisplayOptions = MessageDisplayOptions(),
messagePaddings: MessagePaddings = MessagePaddings(),
dateIndicatorPlacement: DateIndicatorPlacement = .overlay,
pageSize: Int = 50,
pageSize: Int = 25,
messagePopoverEnabled: Bool = true,
doubleTapOverlayEnabled: Bool = false,
becomesFirstResponderOnOpen: Bool = false,
Expand Down
Loading
Loading