dohoudjann
dohoudjann

Reputation: 67

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
 
       }

Upvotes: -1

Views: 929

Answers (1)

Neklas
Neklas

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

Related Questions