Skip to content

Commit

Permalink
Bidirectional list scrolling (#404)
Browse files Browse the repository at this point in the history
  • Loading branch information
martinmitrevski authored Nov 27, 2023
1 parent 862d162 commit 7df1f28
Show file tree
Hide file tree
Showing 16 changed files with 366 additions and 44 deletions.
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
66 changes: 66 additions & 0 deletions Sources/StreamChatSwiftUI/ChatChannel/ChatChannelDataSource.swift
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

0 comments on commit 7df1f28

Please sign in to comment.