Reputation: 418
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.
Upvotes: 0
Views: 3544
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.
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
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
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