Search code examples
swiftscrollpaginationuicollectionview

Keep the cells in the same position after scrolling down a collectionView


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:

  1. It's glitchy meaning it scroll to the top of the new data then scroll back to the "previous position".
  2. It's not exactly moving back to the previous position, the oldest message before loading the older data is not as the same place, it's almost in the center instead of staying on top. Here's my code:
 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
 
       }

Solution

  • 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
        }
    }