Latenec
Latenec

Reputation: 418

Synchronised Scrolling UICollectionViews in UITableViewCell in Swift

I have the structure like this:

UITableView -> UITableViewCell -> UICollectionView -> UICollectionViewCell

So what I’m trying to achieve is that I want to make UICollectionViews in UITableViewCells to scroll synchronised. For example when you scroll manually the first UICollectionView on the first row, I want the rest of UICollectionViews to follow, but the Text Labels to stay in the same position all the time. (Please see the image below)

EDIT: I know that I have to use contentOffset somehow, but don’t know how to implement in this case scenario. Any help would be appreciated.

Click to see the image

Click to see the gif

Upvotes: 0

Views: 3544

Answers (3)

Giuseppe Lanza
Giuseppe Lanza

Reputation: 3699

I came up with a working solution you can test on a playground:

//: A UIKit based Playground for presenting user interface

import UIKit
import PlaygroundSupport

class MyCollectionCell: UITableViewCell, UICollectionViewDataSource, UICollectionViewDelegate {
  var originatingChange: Bool = false
  var observationToken: NSKeyValueObservation!
  var offsetSynchroniser: OffsetSynchroniser? {
    didSet {
      guard let offsetSynchroniser = offsetSynchroniser else { return }
      collection.setContentOffset(offsetSynchroniser.currentOffset, animated: false)

      observationToken = offsetSynchroniser.observe(\.currentOffset) { (_, _) in
        guard !self.originatingChange else { return }
        self.collection.setContentOffset(offsetSynchroniser.currentOffset, animated: false)
      }
    }
  }

  lazy var collection: UICollectionView = {
    let layout = UICollectionViewFlowLayout()
    layout.itemSize = CGSize(width: 40, height: 40)
    layout.scrollDirection = .horizontal
    let collection = UICollectionView(frame: .zero, collectionViewLayout: layout)
    collection.backgroundColor = .white
    collection.dataSource = self
    collection.delegate = self
    collection.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")

    return collection
  }()

  override func layoutSubviews() {
    super.layoutSubviews()
    collection.frame = contentView.bounds
    contentView.addSubview(collection)
  }

  func numberOfSections(in collectionView: UICollectionView) -> Int {
    return 1
  }

  func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
    return 10
  }

  func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
    cell.layer.borderColor = UIColor.black.cgColor
    cell.layer.borderWidth = 1
    cell.backgroundColor = .white

    return cell
  }

  func scrollViewDidScroll(_ scrollView: UIScrollView) {
    originatingChange = true
    offsetSynchroniser?.currentOffset = scrollView.contentOffset
    originatingChange = false
  }
}

class OffsetSynchroniser: NSObject {
  @objc dynamic var currentOffset: CGPoint = .zero
}

class MyViewController : UIViewController, UITableViewDataSource {
  var tableView: UITableView!
  let offsetSynchroniser = OffsetSynchroniser()

  override func loadView() {
    let view = UIView()
    view.backgroundColor = .white

    tableView = UITableView(frame: .zero, style: .plain)

    tableView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
    view.addSubview(tableView)
    tableView.dataSource = self

    tableView.register(MyCollectionCell.self, forCellReuseIdentifier: "cell")

    self.view = view
  }

  override func viewDidLoad() {
    super.viewDidLoad()
    tableView.reloadData()
  }

  func numberOfSections(in tableView: UITableView) -> Int {
    return 1
  }

  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return 10
  }

  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! MyCollectionCell
    cell.selectionStyle = .none
    cell.collection.reloadData()
    cell.offsetSynchroniser = offsetSynchroniser
    return cell
  }
}
// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()

To make it work with a playground you will see a lot of code that if you are using storyboards or xib is not needed. I hope anyway that the base idea is clear.

Explanation

Basically I created an object called OffsetSynchroniser which has an observable property called currentOffset. Each cell of the tableView accepts an offsetSynchroniser and on didSet they register with KVO for notifications of currentOffset changes.

Each cells also registers to its own collection's delegate and implements the didScroll delegate method.

When any of those collectionView causes this method to be triggered the currentOffset var of the synchroniser is changed and all the cells that are subscribed through KVO will react to the changes.

The Observable object is very simple:

class OffsetSynchroniser: NSObject {
  @objc dynamic var currentOffset: CGPoint = .zero
}

then your tableViewCell will have an instance of this object type and on didSet will register with KVO to the var currentOffset:

  var originatingChange: Bool = false
  var observationToken: NSKeyValueObservation!
  var offsetSynchroniser: OffsetSynchroniser? {
    didSet {
      guard let offsetSynchroniser = offsetSynchroniser else { return }
      collection.setContentOffset(offsetSynchroniser.currentOffset, animated: false)

      observationToken = offsetSynchroniser.observe(\.currentOffset) { (_, _) in
        guard !self.originatingChange else { return }
        self.collection.setContentOffset(offsetSynchroniser.currentOffset, animated: false)
      }
    }
  }

The originatingChange variable is to avoid that the collectionView that is actually initiating the offset change will react by causing the offset to be re-set twice.

Finally, always in your TableViewCell, after registering itself as collectionViewDelegate you will implement the method for didScroll

  func scrollViewDidScroll(_ scrollView: UIScrollView) {
    originatingChange = true
    offsetSynchroniser?.currentOffset = scrollView.contentOffset
    originatingChange = false
  }

In here we can change the currentOffset of the synchroniser.

The tableViewController will at this point just have the ownership for the synchroniser

 class YourTableViewController: UItableViewController { // or whatever ViewController contains an UITableView
      let offsetSynchroniser = OffsetSynchroniser()
      ...
      func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
          let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! MyCollectionCell
          ...
          cell.offsetSynchroniser = offsetSynchroniser
          return cell
      }
 }

Upvotes: 1

Dominik Bucher
Dominik Bucher

Reputation: 2169

Okay I managed to get this working, Please keep in mind the code is just for the question purposes and contains lot of non-generic parameters and force casting that should be avoided at any cost.

The class for MainViewController containing the tableView:

    protocol TheDelegate: class {
    func didScroll(to position: CGFloat)
}

    class ViewController: UIViewController, TheDelegate {

        func didScroll(to position: CGFloat) {
            for cell in tableView.visibleCells as! [TableViewCell] {
                (cell.collectionView as UIScrollView).contentOffset.x = position
            }
        }

        @IBOutlet var tableView: UITableView!

        override func viewDidLoad() {
            super.viewDidLoad()
            tableView.dataSource = self
        }
    }

    extension ViewController: UITableViewDataSource {
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            return 100
        }

        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            guard let cell = tableView.dequeueReusableCell(withIdentifier: "tableCell", for: indexPath) as? TableViewCell else { return UITableViewCell() }
            cell.scrollDelegate = self
            return cell
        }
    }

The class for your tableViewCell:

class TableViewCell: UITableViewCell {

    @IBOutlet var collectionView: UICollectionView!

    weak var scrollDelegate: TheDelegate?

    override func awakeFromNib() {
        super.awakeFromNib()
        (collectionView as UIScrollView).delegate = self
        collectionView.dataSource = self
    }
}

extension TableViewCell: UICollectionViewDataSource {

    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 1
    }

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 100
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "collectionCell", for: indexPath) as! CollectionViewCell
        cell.imageView.image = #imageLiteral(resourceName: "litecoin.png")
        return cell
    }
}

extension TableViewCell: UIScrollViewDelegate {

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        scrollDelegate?.didScroll(to: scrollView.contentOffset.x)
    }
}

The class for the collectionViewCell is irelevant since it's just implementation detail. I will post this solution to github in a second.

Disclaimer: This works just for visible cells. You need to implement the current scroll state for the cells ready for reuse as well. I will extend the code on github.

Upvotes: 6

Jacob Boyd
Jacob Boyd

Reputation: 690

The best way I can think of off the top of my head to do something like this would be to store all of your collectionViews in a collection object. You can then use the UIScrollView's scrollViewDidScroll delegate method from those collectionViews. Just make sure you have your delegate set correctly.

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    for view in collectionViewCollection where view.scrollView != scrollView{
        view.scrollView.contentOffset = scrollView.contentOffset
    }
}

This is untested code, so not a complete answer but it should get you started.

Upvotes: 0

Related Questions