Reputation: 67
I have a chat and when i scroll down to fetch older messages i want the collectionView to stay still and allow the user to manually scroll the older message who just loaded like in messenger. With my collectionView it automatically scroll to the top of the new items. I have added a method inside my scrollViewDidEndDragging but there is 2 problems:
override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
let contentOffset = scrollView.contentOffset.y
if contentOffset <= -40 {
self.collectionView.refreshControl?.beginRefreshing()
self.fetchMessages()
let beforeTableViewContentHeight = collectionView.contentSize.height
let beforeTableViewOffset = collectionView.contentOffset.y
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.collectionView.layer.layoutIfNeeded()
let insertCellHeight = beforeTableViewOffset + (self.collectionView.contentSize.height - beforeTableViewContentHeight)
let newOffSet = CGPoint(x: 0, y: insertCellHeight)
self.collectionView.contentOffset = newOffSet
}
}
}
// MARK: - UICollectionViewDelegateFlowLayout
extension RoomMessageViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
return CGSize(width: view.frame.width, height: 10)
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
return .init(top: 16, left: 0, bottom: 16, right: 0)
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let frame = CGRect(x: 0, y: 0, width: view.frame.width, height: 50)
let estimatedSizeCell = RoomMessageCell(frame: frame)
estimatedSizeCell.roomMessage = chatMessages[indexPath.section][indexPath.row]
estimatedSizeCell.layoutIfNeeded()
let targetSize = CGSize(width: view.frame.width, height: 1000)
let estimatedSize = estimatedSizeCell.systemLayoutSizeFitting(targetSize)
return CGSize(width: view.frame.width, height: estimatedSize.height)
}
}
[Updated code#1]
override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if scrollView.contentOffset.y <= -50 {
self.collectionView.reloadData()
self.fetchMessages()
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) {
let previousContentSize = self.collectionView.contentSize
self.collectionView.collectionViewLayout.invalidateLayout()
self.collectionView.collectionViewLayout.prepare()
self.collectionView.layoutIfNeeded()
let newContentSize = self.collectionView.contentSize
print("previous Content Size \(previousContentSize) and new Content Size \(newContentSize)")
let contentOffset = newContentSize.height - previousContentSize.height
self.collectionView.setContentOffset(CGPoint(x: 0.0, y: contentOffset), animated: false)
}
}
}
[Updated Code#2]
var lastDocumentSnapshot: DocumentSnapshot!
var isScrollBottom = false
var isFirstLoad = true
var isLoading = false
private var messages = [RoomMessage]()
private var chatMessages = [[RoomMessage]]()
override func viewDidAppear(_ animated: Bool) {
self.isScrollBottom = true
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if !isScrollBottom {
DispatchQueue.main.async(execute: {
self.collectionView.scrollToBottom(animated: false)
self.isScrollBottom = true
self.isFirstLoad = false
})
}
}
func fetchMessages() {
var query: Query!
guard let room = room else{return}
guard let roomID = room.recentMessage.roomID else{return}
collectionView.refreshControl?.beginRefreshing()
if messages.isEmpty {
query = COLLECTION_ROOMS.document(roomID).collection("messages").order(by: "timestamp", descending: false).limit(toLast: 15)
print("First 15 msg loaded")
} else {
query = COLLECTION_ROOMS.document(roomID).collection("messages").order(by: "timestamp", descending: false).end(beforeDocument: lastDocumentSnapshot).limit(toLast: 15)
print("Next 15 msg loaded")
}
query.addSnapshotListener { (snapshot, err) in
if let err = err {
print("\(err.localizedDescription)")
} else if snapshot!.isEmpty {
self.collectionView.refreshControl?.endRefreshing()
return
}
guard let lastSnap = snapshot?.documents.first else {return}
self.lastDocumentSnapshot = lastSnap
snapshot?.documentChanges.forEach({ (change) in
if change.type == .added {
let dictionary = change.document.data()
let timestamp = dictionary["timestamp"] as? Timestamp
var message = RoomMessage(dictionary: dictionary)
let date = timestamp?.dateValue()
let formatter1 = DateFormatter()
// formatter1.dateStyle = .medium
formatter1.timeStyle = .short
message.timestampHour = formatter1.string(from: date!)
message.timestampDate = date!
self.messages.append(message)
self.messages.sort(by: { $0.timeStamp.compare($1.timeStamp) == .orderedAscending })
}
self.attemptToAssembleGroupedMessages { (assembled) in
if assembled {
}
}
})
self.collectionView.refreshControl?.endRefreshing()
self.lastDocumentSnapshot = snapshot?.documents.first
}
}
// MARK: - Helpers
func configureUI() {
// collectionView.alwaysBounceVertical = true
self.hideKeyboardOnTap()
if let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout {
layout.sectionHeadersPinToVisibleBounds = true
}
let refreshControl = UIRefreshControl()
collectionView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(handleRefresh), for: .valueChanged)
}
fileprivate func attemptToAssembleGroupedMessages(completion: (Bool) -> ()){
chatMessages.removeAll()
let groupedMessages = Dictionary(grouping: messages) { (element) -> Date in
return element.timestampDate.reduceToMonthDayYear() }
// provide a sorting for the keys
let sortedKeys = groupedMessages.keys.sorted()
sortedKeys.forEach { (key) in
let values = groupedMessages[key]
chatMessages.append(values ?? [])
self.collectionView.reloadData()
}
completion(true)
}
@objc func handleRefresh() {
}
}
extension RoomMessageViewController {
//NEKLAS METHOD
override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
let contentOffset = scrollView.contentOffset.y
var lastContentSize = scrollView.contentSize
var currentOffset = scrollView.contentOffset
if contentOffset <= -50 {
self.isLoading = true
self.collectionView.reloadData()
self.fetchMessages()
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) {
self.collectionView.layer.layoutIfNeeded()
let newContentSize = self.collectionView.contentSize
let delta = newContentSize.height - lastContentSize.height
lastContentSize = self.collectionView.contentSize
if delta > 0 {
currentOffset.y = currentOffset.y + delta
self.collectionView.setContentOffset(currentOffset, animated: false)
if self.isLoading { return }
self.isLoading = false
}
}
}
}
//ANOTHER METHOD (More precise but has some bug)
// override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
// if scrollView.contentOffset.y <= -50 {
// self.collectionView.reloadData()
// self.fetchMessages()
// DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) {
// let previousContentSize = self.collectionView.contentSize
// self.collectionView.collectionViewLayout.invalidateLayout()
// self.collectionView.collectionViewLayout.prepare()
// self.collectionView.layoutIfNeeded()
// let newContentSize = self.collectionView.contentSize
// print("previous Content Size \(previousContentSize) and new Content Size \(newContentSize)")
// let contentOffset = newContentSize.height - previousContentSize.height
// self.collectionView.setContentOffset(CGPoint(x: 0.0, y: contentOffset), animated: false)
// }
// }
//}
[Updated Code #3]
var currentOffset: CGPoint = .zero
var lastContentSize: CGSize = .zero
var currentPage = 1
@objc func fetchMessages() {
var query: Query!
guard let room = room else{return}
guard let roomID = room.recentMessage.roomID else{return}
collectionView.refreshControl?.beginRefreshing()
if messages.isEmpty {
query = COLLECTION_ROOMS.document(roomID).collection("messages").order(by: "timestamp", descending: false).limit(toLast: 15)
print("First 15 msg loaded")
} else {
query = COLLECTION_ROOMS.document(roomID).collection("messages").order(by: "timestamp", descending: false).end(beforeDocument: lastDocumentSnapshot).limit(toLast: 15)
print("Next 15 msg loaded")
self.currentPage = self.currentPage + 1
print(self.currentPage)
}
query.addSnapshotListener { (snapshot, err) in
if let err = err {
print("\(err.localizedDescription)")
} else if snapshot!.isEmpty {
self.collectionView.refreshControl?.endRefreshing()
return
}
guard let lastSnap = snapshot?.documents.first else {return}
self.lastDocumentSnapshot = lastSnap
snapshot?.documentChanges.forEach({ (change) in
if change.type == .added {
let dictionary = change.document.data()
let timestamp = dictionary["timestamp"] as? Timestamp
var message = RoomMessage(dictionary: dictionary)
let date = timestamp?.dateValue()
let formatter1 = DateFormatter()
formatter1.timeStyle = .short
message.timestampHour = formatter1.string(from: date!)
message.timestampDate = date!
self.messages.append(message)
self.messages.sort(by: { $0.timeStamp.compare($1.timeStamp) == .orderedAscending })
self.attemptToAssembleGroupedMessages { (assembled) in
if assembled {
}
}
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .milliseconds(10)) {
if self.currentPage == 1 {
self.collectionView.reloadData()
self.collectionView.scrollToBottom(animated: false)
} else {
self.collectionView.reloadData()
self.collectionView.layoutIfNeeded()
let newContentSize = self.collectionView.contentSize
let delta = newContentSize.height - self.lastContentSize.height
self.lastContentSize = self.collectionView.contentSize
if delta > 0 {
self.currentOffset.y = self.currentOffset.y + delta
self.collectionView.setContentOffset(self.currentOffset, animated: false)
}
}
self.collectionView.refreshControl?.endRefreshing()
self.lastDocumentSnapshot = snapshot?.documents.first
}
}
})
}
}
fileprivate func attemptToAssembleGroupedMessages(completion: (Bool) -> ()){
chatMessages.removeAll()
let groupedMessages = Dictionary(grouping: messages) { (element) -> Date in
return element.timestampDate.reduceToMonthDayYear() }
// provide a sorting for the keys
let sortedKeys = groupedMessages.keys.sorted()
sortedKeys.forEach { (key) in
let values = groupedMessages[key]
chatMessages.append(values ?? [])
// self.collectionView.reloadData()
}
completion(true)
}
}
extension RoomMessageViewController {
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
// We capture any change of offSet/contentSize
self.lastContentSize = scrollView.contentSize
self.currentOffset = scrollView.contentOffset
}
Upvotes: -1
Views: 929
Reputation: 542
Here are your updated codes:
var isFirstLoad = true
var isLoading = false
// here is place when you get your message after fetching
// THIS IS FIRST LOAD WHEN YOU OPEN SCREEN
// If pageNumber is 1 or isFirstLoad = true
// After fetching data, reloadData and scroll to lastIndex (newest message), must call this to get the final contentSize on firstLoad.
self.tableViewVideo.reloadData()
let lastIndexPath = IndexPath(row: self.listVideo.count - 1, section: 0)
self.tableViewVideo.scrollToRow(at: lastIndexPath, at: .bottom, animated: true)
self.isFirstLoad = false // reset it
Next, in your scrollViewDidScroll(scrollView: UIScrollView)
func scrollViewDidScroll(scrollView: UIScrollView) {
let contentOffset = scrollView.contentOffset.y
self.lastContentSize = scrollView.contentSize
self.currentOffset = scrollView.contentOffset
if contentOffset <= -50 {
if self.isLoading { return } // Here is the FLAG can help you to avoid spamming scrolling that trigger history loading
self.isLoading = true
self.fetchMessages() // update your list then reloadData, end refreshing, set isLoading = false (reset FLAG)
}
}
After you have new data from history fetching, reloadData(), then now you can do your animation. This is for history loading.
self.collectionView.reloadData()
self.collectionView.layoutIfNeeded()
let newContentSize = self.collectionView.contentSize
let delta = newContentSize.height - self.lastContentSize.height
self.lastContentSize = self.tableViewVideo.contentSize
if delta > 0 {
self.currentOffset.y = self.currentOffset.y + delta
self.collectionView.setContentOffset(self.currentOffset, animated: false)
}
self.collectionView.refreshControl?.endRefreshing()
Entire solution with real example:
import UIKit
struct VideoItem {
var thumbnailURL = ""
var videoURL = ""
var name = ""
}
class TaskListScreen: UIViewController {
@IBOutlet weak var tableViewVideo: UITableView!
@IBOutlet weak var labelHost: UILabel!
var listVideo: [VideoItem] = []
var currentOffset: CGPoint = .zero
var lastContentSize: CGSize = .zero
var isLoading = false
var currentPage = 1
override func viewDidLoad() {
super.viewDidLoad()
let refresh = UIRefreshControl()
refresh.tintColor = .red
refresh.addTarget(self, action: #selector(loadHistory), for: .valueChanged)
self.tableViewVideo.refreshControl = refresh
self.setupTableView()
self.initData()
self.showSkeletonLoadingView() // cover your message list
self.loadHistory() // currentPage = 1 mean latest messages
}
private func setupTableView() {
tableViewVideo.register(UINib(nibName: "CustomCell", bundle: nil), forCellReuseIdentifier: "CustomCell")
tableViewVideo.dataSource = self
tableViewVideo.delegate = self
}
@objc func loadHistory() {
let data: [VideoItem] = [
.init(thumbnailURL: "https://i.ytimg.com/vi/qk2y-TiLDZo/hqdefault.jpg", videoURL: "https://multiplatform-f.akamaihd.net/i/multi/will/bunny/big_buck_bunny_,640x360_400,640x360_700,640x360_1000,950x540_1500,.f4v.csmil/master.m3u8", name: "Big Buck Bunny"),
.init(thumbnailURL: "https://i.ytimg.com/vi/qk2y-TiLDZo/hqdefault.jpg", videoURL: "https://multiplatform-f.akamaihd.net/i/multi/will/bunny/big_buck_bunny_,640x360_400,640x360_700,640x360_1000,950x540_1500,.f4v.csmil/master.m3u8", name: "Big Buck Bunny"),
.init(thumbnailURL: "https://i.ytimg.com/vi/qk2y-TiLDZo/hqdefault.jpg", videoURL: "https://multiplatform-f.akamaihd.net/i/multi/will/bunny/big_buck_bunny_,640x360_400,640x360_700,640x360_1000,950x540_1500,.f4v.csmil/master.m3u8", name: "Big Buck Bunny"),
.init(thumbnailURL: "https://i.ytimg.com/vi/qk2y-TiLDZo/hqdefault.jpg", videoURL: "https://multiplatform-f.akamaihd.net/i/multi/will/bunny/big_buck_bunny_,640x360_400,640x360_700,640x360_1000,950x540_1500,.f4v.csmil/master.m3u8", name: "Big Buck Bunny"),
.init(thumbnailURL: "https://i.ytimg.com/vi/qk2y-TiLDZo/hqdefault.jpg", videoURL: "https://multiplatform-f.akamaihd.net/i/multi/will/bunny/big_buck_bunny_,640x360_400,640x360_700,640x360_1000,950x540_1500,.f4v.csmil/master.m3u8", name: "Big Buck Bunny"),
.init(thumbnailURL: "https://i.ytimg.com/vi/qk2y-TiLDZo/hqdefault.jpg", videoURL: "https://multiplatform-f.akamaihd.net/i/multi/will/bunny/big_buck_bunny_,640x360_400,640x360_700,640x360_1000,950x540_1500,.f4v.csmil/master.m3u8", name: "Big Buck Bunny"),
.init(thumbnailURL: "https://i.ytimg.com/vi/qk2y-TiLDZo/hqdefault.jpg", videoURL: "https://multiplatform-f.akamaihd.net/i/multi/will/bunny/big_buck_bunny_,640x360_400,640x360_700,640x360_1000,950x540_1500,.f4v.csmil/master.m3u8", name: "Big Buck Bunny"),
.init(thumbnailURL: "https://i.ytimg.com/vi/qk2y-TiLDZo/hqdefault.jpg", videoURL: "https://multiplatform-f.akamaihd.net/i/multi/will/bunny/big_buck_bunny_,640x360_400,640x360_700,640x360_1000,950x540_1500,.f4v.csmil/master.m3u8", name: "Big Buck Bunny"),
.init(thumbnailURL: "https://i.ytimg.com/vi/qk2y-TiLDZo/hqdefault.jpg", videoURL: "https://multiplatform-f.akamaihd.net/i/multi/will/bunny/big_buck_bunny_,640x360_400,640x360_700,640x360_1000,950x540_1500,.f4v.csmil/master.m3u8", name: "Big Buck Bunny"),
.init(thumbnailURL: "https://i.ytimg.com/vi/qk2y-TiLDZo/hqdefault.jpg", videoURL: "https://multiplatform-f.akamaihd.net/i/multi/will/bunny/big_buck_bunny_,640x360_400,640x360_700,640x360_1000,950x540_1500,.f4v.csmil/master.m3u8", name: "Big Buck Bunny")]
// suppose that api takes 2 sec to finish
if self.currentPage == 1 { self.listVideo.removeAll() } // reset for first load
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 2) { [weak self] in
guard let _self = self else { return }
_self.listVideo.insert(contentsOf: data, at: 0) // update your data source here
if _self.currentPage == 1 {
_self.tableViewVideo.reloadData()
let lastIndexPath = IndexPath(row: _self.listVideo.count - 1, section: 0)
_self.tableViewVideo.scrollToRow(at: lastIndexPath, at: .bottom, animated: true)
_self.hideYourSkeletonLoadingView() // hide the cover that is covering your message list
} else {
_self.tableViewVideo.reloadData()
_self.tableViewVideo.layoutIfNeeded()
let newContentSize = _self.tableViewVideo.contentSize
let delta = newContentSize.height - _self.lastContentSize.height
_self.lastContentSize = _self.tableViewVideo.contentSize
if delta > 0 {
_self.currentOffset.y = _self.currentOffset.y + delta
_self.tableViewVideo.setContentOffset(_self.currentOffset, animated: false)
}
_self.tableViewVideo.refreshControl?.endRefreshing()
}
_self.currentPage += 1 // move to next page, for next load
}
}
}
extension TaskListScreen: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return listVideo.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "CustomCell", for: indexPath) as! CustomCell
let item = listVideo[indexPath.item]
cell.labelTitle.text = "Book: \(indexPath.item + 1)" //item.name
return cell
}
}
extension TaskListScreen: UITableViewDelegate {
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return 120
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// We capture any change of offSet/contentSize
self.lastContentSize = scrollView.contentSize
self.currentOffset = scrollView.contentOffset
}
}
Upvotes: 1