TableView freeze - ios

I have an array of items and TableView to display them. Item consists of 4 properties. And there is a method which randomly generate items. Initialy, array is empty but in viewDidLoad i have method, which append 100 items to array with delay about 1 second. Array appends until its count about 1_000_000 items. When I start app it freeze.
It's my tableView methods:
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return model.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: DealCell.reuseIidentifier, for: indexPath) as! DealCell
guard model.count != 0 else {
cell.instrumentNameLabel.text = "no data"
cell.priceLabel.text = "no data"
cell.amountLabel.text = "no data"
cell.sideLabel.text = "no data"
return cell
}
cell.instrumentNameLabel.text = "\(model[indexPath.row].instrumentName)"
cell.priceLabel.text = "\(model[indexPath.row].price)"
cell.amountLabel.text = "\(model[indexPath.row].amount)"
cell.sideLabel.text = "\(model[indexPath.row].side)"
return cell
}
Function to append array:
server.subscribeToUpdate { updates in
self.model.append(contentsOf: updates)
self.tableView.reloadData()
}
How to solve this problem? May be to make some counter for "numberOfRowsInSection" equal to 100 and then when scroll to 100th item increase it to 200 etc. Or is there a more concise solution?
Tried to use ReusableCell, but nothing happened.

I don't know what you are really trying to do here... your code seems like it's just doing a "stress test" or something.
However, to try and help you understand why your app is "freezing" --
Your for i in 0...dealsCount { loop will be running very fast. As in maybe 1 or 2 thousandths of a second per 100 iterations. If you are calling .reloadData() every 100th time through the loop, your code is trying to update the UI pretty much constantly. That means your UI will appear "frozen."
Here's an example that you may find helpful...
First, we'll add a "status label" and a progress view to display the progress as we generate Deals. We'll update those every 1000th new Deal created.
Second, as we generate new Deals, we'll append them directly to the controller's var model: [Deal] = [] array (instead of building new arrays and appending them periodically).
Third, we'll only call .reloadData():
at the first 1,000 Deals
then at every 100,000 Deals
and finally after we've generated all 1-million+
As I said, I don't know what you're really doing ... but it is unlikely someone would scroll through the first 1,000 rows before we add the next 100,000 records.
However, you'll find that you can scroll the table while the records are being generated... and, after they've all been generated, selecting any cell will jump to the 900,000th row.
Here's how it looks:
Deal Struct
struct Deal {
var id: Int64 = 0
var dateModifier: Date = Date()
var instrumentName: String = ""
var price: Double = 0
var amount: Double = 0
}
Simple multi-line label cell class
class DealCell: UITableViewCell {
let theLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
theLabel.numberOfLines = 0
theLabel.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
theLabel.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(theLabel)
let g = contentView.layoutMarginsGuide
NSLayoutConstraint.activate([
theLabel.topAnchor.constraint(equalTo: g.topAnchor),
theLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor),
theLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor),
theLabel.bottomAnchor.constraint(equalTo: g.bottomAnchor),
])
}
func fillData(_ aDeal: Deal) {
let i = aDeal.id
let d = aDeal.dateModifier
let nm = aDeal.instrumentName
let p = String(format: "%0.2f", aDeal.price)
let a = String(format: "%0.2f", aDeal.amount)
theLabel.text = "\(i): \(nm)\nPrice: \(p) / Amount: \(a)\n\(d)"
}
}
Demo view controller
class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
var model: [Deal] = []
let instrumentNames: [String] = [
"accordion",
"acoustic guitar",
"bagpipes",
"banjo",
"bass guitar",
"bongo drums",
"bugle",
"cello",
"clarinet",
"cymbals",
"drums",
"electric guitar",
"flute",
"French horn",
"harmonica",
"harp",
"keyboard",
"maracas",
"organ",
"pan flute (pan pipes)",
"piano",
"recorder",
"saxophone",
"sitar",
"tambourine",
"triangle",
"trombone",
"trumpet",
"tuba",
"ukulele",
"violin",
"xylophone",
]
let tableView = UITableView()
let progressView = UIProgressView()
let statusLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
[statusLabel, progressView, tableView].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
}
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
statusLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 8.0),
statusLabel.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0),
statusLabel.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -8.0),
progressView.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 8.0),
progressView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
progressView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
tableView.topAnchor.constraint(equalTo: progressView.bottomAnchor, constant: 8.0),
tableView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
tableView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
tableView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
])
tableView.register(DealCell.self, forCellReuseIdentifier: "c")
tableView.dataSource = self
tableView.delegate = self
statusLabel.textAlignment = .center
statusLabel.textColor = .systemRed
subscribeToDeals()
}
func updateProgress(currentCount: Int64, futureCount: Int64) {
// update the status Lable and progress bar
statusLabel.text = "Generated \(currentCount) of \(futureCount)"
progressView.progress = Float(currentCount) / Float(futureCount)
// only reload the table if we're at
// the first 1_000, or
// every 100_000, or
// we're finished generating Deals
// if it's the first update
if currentCount == 1_000 {
tableView.reloadData()
}
// else if we're at an even 100_000
else if currentCount % 100_000 == 0 {
tableView.reloadData()
}
// else if we've generated all
else if currentCount == futureCount {
tableView.reloadData()
}
}
func subscribeToDeals() {
let bkgQueue = DispatchQueue(label: "subscribing", qos: .background)
bkgQueue.async{
let dealsCount = Int64.random(in: 1_000_000..<1_001_000)
for i in 0...dealsCount {
let currentTimeStamp = Date().timeIntervalSince1970
let timeStampRandomizer = Double.random(in: 50_000...50_000_000)
let deal = Deal (
id: i,
dateModifier: Date(timeIntervalSince1970: Double.random(in: currentTimeStamp - timeStampRandomizer...currentTimeStamp)),
instrumentName: self.instrumentNames.shuffled().first!,
price: Double.random(in: 60...70),
amount: Double.random(in: 1_000_000...50_000_000)
)
// append directly to data
self.model.append(deal)
// if we're at a 1_000 point
if i % 1_000 == 0 {
DispatchQueue.main.async {
self.updateProgress(currentCount: i, futureCount: dealsCount)
}
}
}
// we've generated all deals
DispatchQueue.main.async {
self.updateProgress(currentCount: dealsCount, futureCount: dealsCount)
}
print("Done generating \(dealsCount) Deals!")
}
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return model.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let c = tableView.dequeueReusableCell(withIdentifier: "c", for: indexPath) as! DealCell
c.fillData(model[indexPath.row])
return c
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if model.count > 1_000_000 {
tableView.scrollToRow(at: IndexPath(row: 900_000, section: 0), at: .middle, animated: false)
}
}
}
Note: this is Example Code Only!!! It is not intended to be, and should not be considered to be, "production ready."

The code below should solve your problem by unblocking the main thread using DispatchQueue
server.subscribeToDeals { deals in
self.model.append(contentsOf: deals)
DispatchQueue.main.async {
self.tableView.reloadData()
}
}

Related

How to sum total the attendance of various age groups in third section of tableview using swift instead of implementing new controller?

Here is the picture of what has been implemented till now.
Aim: To show the total of the the attendance of the age group in the third section in table view.
What we applied: Tried applying the label with new variable that can count the cell of the increase and decrease button function.
What is the issue: The increase and decrease button click still gets repeated in the third section of the tableview where we want only label to show the total of the attendance of the age group
What we Tried:
1.Tried applying the label with new variable that can count the cell of the increase and decrease button function, unfortunately the app crashes if ["0"] is used for new variable to implement total sum. --> var itemValue2 = ["0"]
2.Tried to implement using new controller but same thing happens.
Below is the code of the class AdditionalGuestInformationVC::
import UIKit
import ObjectiveC
import SSSpinnerButton
class AdditionalGuestInforTVCell: UITableViewCell {
#IBOutlet weak var lblName: UILabel!
#IBOutlet weak var lblDetails: UILabel!
#IBOutlet var btnMinus: UIButton!
#IBOutlet var lblValue: UILabel!
#IBOutlet var btnPlus: UIButton!
}
class AdditionalGuestInformationVC: UIViewController, UITableViewDelegate, UITableViewDataSource {
let section = ["Male", "Female", "total"]
let itemsName = [["Shishu", "Baal", "Kishore", "Tarun", "Yuva", "Jyeshta"], ["Shishu", "Baalika", "Kishori", "Taruni", "Yuvati", "Jyeshtaa"], ["Total:"]]
let itemsDetails = [["Below 5 Years - Pre-primary ", "5 to 11 Years - Primary School", "11 to 16 Years - Middle School", "17 to 25 Years - High School/College", "25 to 60 Years - Adults", ">60 Years - Senior citizen"], ["Below 5 Years - Pre-primary ", "5 to 11 Years - Primary School", "11 to 16 Years - Middle School", "17 to 25 Years - High School/College", "25 to 60 Years - Adults", ">60 Years - Senior citizen"],["Attendees"]]
var itemValue = [["0", "0", "0", "0", "0", "0"], ["0", "0", "0", "0", "0", "0"], ["0"]]
var dicMember = [[String:Any]]()
var strDate = ""
var utsavName = ""
#IBOutlet weak var tableView: UITableView!
#IBOutlet var lblShakhaName: UILabel!
#IBOutlet var lblDate: UILabel!
#IBOutlet var btnSubmit: SSSpinnerButton!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
self.fillData()
}
override func viewWillAppear(_ animated: Bool) {
navigationBarDesign(txt_title: "guest_information".localized, showbtn: "back")
self.firebaseAnalytics(_eventName: "AdditionalGuestInfoVC")
}
func fillData() {
self.lblDate.text = strDate
}
#objc func onMinusClick(_ sender: UIButton) {
let sectionIndex = ((sender.tag / 10) - 10)
let rowIndex = (sender.tag % 10)
let indexPath = NSIndexPath(row: rowIndex, section: sectionIndex)
let cell = tableView.cellForRow(at: indexPath as IndexPath) as? AdditionalGuestInforTVCell
var strVal : Int = Int((cell?.lblValue.text)!)!
if strVal > 0 {
strVal -= 1
}
self.itemValue[sectionIndex][rowIndex] = "\(Int(strVal))"
tableView.reloadRows(at:[indexPath as IndexPath], with:.automatic)
}
#objc func onPlusClick(_ sender: UIButton) {
let sectionIndex = ((sender.tag / 10) - 10)
let rowIndex = (sender.tag % 10)
let indexPath = NSIndexPath(row: rowIndex, section: sectionIndex)
let cell = tableView.cellForRow(at: indexPath as IndexPath) as? AdditionalGuestInforTVCell
var strVal : Int = Int((cell?.lblValue.text)!)!
if strVal < 99 {
strVal += 1
}
self.itemValue[sectionIndex][rowIndex] = "\(Int(strVal))"
tableView.reloadRows(at:[indexPath as IndexPath], with:.automatic)
}
#IBAction func onPreviewClick(_ sender: UIButton) {
let storyBoard = UIStoryboard(name: "Main", bundle: nil)
let vc = storyBoard.instantiateViewController(withIdentifier: "AttendancePreviewVC") as! AttendancePreviewVC
vc.itemsName = self.itemsName
vc.itemsDetails = self.itemsDetails
vc.itemValue = self.itemValue
vc.dicMember = self.dicMember
self.navigationController?.pushViewController(vc, animated: true)
}
#IBAction func onSubmitClick(_ sender: UIButton) {
btnSubmit.startAnimate(spinnerType: SpinnerType.circleStrokeSpin, spinnercolor: .white, spinnerSize: 20, complete: {
// Your code here
self.addSankhyaAPI()
let disableMyButton = sender as? UIButton
disableMyButton?.isEnabled = false
})
}
// MARK: TableView Methods
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String?
{
return self.section[section]
}
func numberOfSections(in tableView: UITableView) -> Int
{
// #warning Incomplete implementation, return the number of sections
return self.section.count
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let rect = CGRect(x: 20, y: 0, width: tableView.frame.size.width - 40, height: 60)
let footerView = UIView(frame:rect)
footerView.backgroundColor = UIColor.init(red: 229.0/255.0, green: 229.0/255.0, blue: 229.0/255.0, alpha: 1.0)
let lblTitle = UILabel(frame: CGRect(x: 20.0, y: footerView.center.y / 2, width: 160, height: 30))
lblTitle.text = "Guest Information"
lblTitle.font = UIFont(name: "SourceSansPro-Regular", size: 20.0)
lblTitle.backgroundColor = UIColor.clear
lblTitle.textColor = UIColor.init(red: 154.0/255.0, green: 154.0/255.0, blue: 154.0/255.0, alpha: 1.0)
let lblGender = UILabel(frame: CGRect(x: 30.0 + lblTitle.frame.width, y: footerView.center.y / 2, width: 80, height: 30))
lblGender.text = self.section[section]
lblGender.font = UIFont(name: "SourceSansPro-SemiBold", size: 16.0)
lblGender.textColor = UIColor.black
lblGender.backgroundColor = UIColor.init(red: 255.0/255.0, green: 230.0/255.0, blue: 190.0/255.0, alpha: 1.0)
lblGender.layer.cornerRadius = 15.0
lblGender.layer.masksToBounds = true
lblGender.textAlignment = .center
footerView.addSubview(lblGender)
footerView.addSubview(lblTitle)
return footerView
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return 60 // or whatever
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.itemsName[section].count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "AdditionalGuestInforTVCell", for: indexPath) as! AdditionalGuestInforTVCell
cell.lblName.text = self.itemsName[indexPath.section][indexPath.row]
cell.lblDetails.text = self.itemsDetails[indexPath.section][indexPath.row]
cell.lblValue.text = self.itemValue[indexPath.section][indexPath.row]
cell.btnMinus.addTarget(self, action: #selector(onMinusClick(_:)), for: .touchUpInside)
cell.btnPlus.addTarget(self, action: #selector(onPlusClick(_:)), for: .touchUpInside)
let strSection : String = "\(indexPath.section + 10)"
let strRow = "\(indexPath.row)"
cell.btnPlus.tag = Int(strSection + strRow)!
cell.btnMinus.tag = Int(strSection + strRow)!
cell.lblValue.tag = Int(strSection + strRow)!
return cell
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return UITableView.automaticDimension //Choose your custom row height
}
func addSankhyaAPI() {
let arrFilter : [String] = self.dicMember.filter { $0["isPresent"] as! String == "true" }.map { $0["member_id"]! as! String }
let stringArr = arrFilter.joined(separator: " ")
let stringArray = stringArr.replacingOccurrences(of: " ", with: ", ", options: .literal, range: nil)
var parameters: [String: Any] = [:]
parameters["user_id"] = _appDelegator.dicMemberProfile![0]["user_id"] as? String // dicUserDetails["user_id"]
parameters["event_date"] = strDate
parameters["org_chapter_id"] = _appDelegator.dicMemberProfile![0]["shakha_id"]
parameters["utsav"] = utsavName
parameters["member_id"] = stringArray
parameters["shishu_male"] = self.itemValue[0][0]
parameters["baal"] = self.itemValue[0][1]
parameters["kishore"] = self.itemValue[0][2]
parameters["tarun"] = self.itemValue[0][3]
parameters["yuva"] = self.itemValue[0][4]
parameters["proudh"] = self.itemValue[0][5]
parameters["shishu_female"] = self.itemValue[1][0]
parameters["baalika"] = self.itemValue[1][1]
parameters["kishori"] = self.itemValue[1][2]
parameters["taruni"] = self.itemValue[1][3]
parameters["yuvati"] = self.itemValue[1][4]
parameters["proudha"] = self.itemValue[1][5]
parameters["total:"] = self.itemValue[2]
parameters["api"] = "yes"
print(parameters)
APIManager.sharedInstance.callPostApi(url: APIUrl.add_sankhya, parameters: parameters) { (jsonData, error) in
if error == nil
{
if let status = jsonData!["status"].int
{
if status == 1
{
self.btnSubmit.stopAnimationWithCompletionTypeAndBackToDefaults(completionType: .success, backToDefaults: true, complete: {
// Your code here
if let strMessage = jsonData!["message"].string {
showAlert(title: APP.title, message: strMessage)
}
})
}else {
self.btnSubmit.stopAnimationWithCompletionTypeAndBackToDefaults(completionType: .fail, backToDefaults: true, complete: {
// Your code here
if let strError = jsonData!["message"].string {
showAlert(title: APP.title, message: strError)
}
})
}
} else {
self.btnSubmit.stopAnimationWithCompletionTypeAndBackToDefaults(completionType: .fail, backToDefaults: true, complete: {
// Your code here
if let strError = jsonData!["message"].string {
showAlert(title: APP.title, message: strError)
}
})
}
}
}
}
}
You want to move your button actions inside your AdditionalGuestInforTVCell cell class, then use a closure to tell the controller that the value changed.
Use a second UITableViewCell class as your "Total" cell - with two labels, so it looks like this:
When your AdditionalGuestInforTVCell cell tells the controller its value changed, update your data, get the sum of all the "items" and reload the 3rd section.
Here's a quick example...
First, let's use a struct to define your data:
struct ItemStruct {
var itemName: String = ""
var itemDetails: String = ""
var itemCount: Int = 0
}
In your controller, we'll use a var property like this:
// our data is now an Array of ItemStruct Arrays
// so we'll have one Array per section
var myData: [[ItemStruct]] = []
and we'll initialize the data like this...
In viewDidLoad():
override func viewDidLoad() {
super.viewDidLoad()
// all the normal view setup
myData = getData()
}
func getData() -> [[ItemStruct]] {
let itemsName = [
["Shishu", "Baal", "Kishore", "Tarun", "Yuva", "Jyeshta"],
["Shishu", "Baalika", "Kishori", "Taruni", "Yuvati", "Jyeshtaa"],
]
let itemsDetails = [
["Below 5 Years - Pre-primary ", "5 to 11 Years - Primary School", "11 to 16 Years - Middle School", "17 to 25 Years - High School/College", "25 to 60 Years - Adults", ">60 Years - Senior citizen"],
["Below 5 Years - Pre-primary ", "5 to 11 Years - Primary School", "11 to 16 Years - Middle School", "17 to 25 Years - High School/College", "25 to 60 Years - Adults", ">60 Years - Senior citizen"],
]
var tmpArray: [[ItemStruct]] = []
// let's fill our data with names and details
for (names, details) in zip(itemsName, itemsDetails) {
var secData: [ItemStruct] = []
for i in 0..<names.count {
let d = ItemStruct(itemName: names[i], itemDetails: details[i], itemCount: 0)
secData.append(d)
}
tmpArray.append(secData)
}
return tmpArray
}
Here's a complete, runnable example. We create everything via code - no #IBOutlet or #IBAction connections - so all you need to do is assign the custom class of a plain UIViewController as AdditionalGuestInformationVC. I've tried to include enough in-line comments in the code to make things clear:
struct ItemStruct {
var itemName: String = ""
var itemDetails: String = ""
var itemCount: Int = 0
}
class TotalCell: UITableViewCell {
var lblTitle = UILabel()
var lblValue = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
// label properties
lblTitle.text = "Total Attendees:"
lblValue.textAlignment = .center
// add views to contentView
[lblTitle, lblValue].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(v)
}
// use content view's defaualt margins
let g = contentView.layoutMarginsGuide
// this avoids autolayout complaints
let c = lblTitle.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0)
c.priority = .required - 1
NSLayoutConstraint.activate([
// Name label Top / Leading
lblTitle.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
lblTitle.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
lblTitle.heightAnchor.constraint(equalToConstant: 50.0),
c,
// Value label Trailing
lblValue.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
lblValue.widthAnchor.constraint(equalToConstant: 40.0),
// vertically center Value label
lblValue.centerYAnchor.constraint(equalTo: g.centerYAnchor),
])
}
func fillData(_ value: Int) {
lblValue.text = "\(value)"
}
}
class AdditionalGuestInforTVCell: UITableViewCell {
// closure to inform the controller that the count changed
var countChanged: ((UITableViewCell, Int) -> ())?
var lblName = UILabel()
var lblDetails = UILabel()
var btnMinus = UIButton()
var lblValue = UILabel()
var btnPlus = UIButton()
// we'll automatically update the value label when the count changes
var currentItemCount: Int = 0 {
didSet {
lblValue.text = "\(currentItemCount)"
}
}
#objc func minusTap() {
if currentItemCount > 0 {
// decrement current count
currentItemCount -= 1
// inform the controller
countChanged?(self, currentItemCount)
}
}
#objc func plusTap() {
// increment current count
currentItemCount += 1
// inform the controller
countChanged?(self, currentItemCount)
}
// we'll fill the labels from our data struct object
func fillData(_ data: ItemStruct) {
lblName.text = "\(data.itemName)"
lblDetails.text = "\(data.itemDetails)"
self.currentItemCount = data.itemCount
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
// label properties
lblDetails.font = .systemFont(ofSize: 14.0)
lblDetails.textColor = .gray
lblDetails.numberOfLines = 0
lblValue.textAlignment = .center
// button titles
btnMinus.setTitle("-", for: [])
btnPlus.setTitle("+", for: [])
// button style
[btnMinus, btnPlus].forEach { b in
b.setTitleColor(.black, for: .normal)
b.setTitleColor(.lightGray, for: .highlighted)
b.layer.cornerRadius = 6
b.layer.borderWidth = 1
b.layer.borderColor = UIColor.lightGray.cgColor
}
// add views to contentView
[lblName, lblDetails, btnMinus, lblValue, btnPlus].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(v)
}
// use content view's defaualt margins
let g = contentView.layoutMarginsGuide
// this avoids autolayout complaints
let c = lblDetails.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0)
c.priority = .required - 1
NSLayoutConstraint.activate([
// Name label Top / Leading
lblName.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
lblName.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
// Details label Top (to Name label Bottom) / Leading / Bottom
lblDetails.topAnchor.constraint(equalTo: lblName.bottomAnchor, constant: 6.0),
lblDetails.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
c,
// plus button Trailing
btnPlus.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
// Value label Trailing (to Plus button Leading)
lblValue.trailingAnchor.constraint(equalTo: btnPlus.leadingAnchor, constant: 0.0),
// Minus button Trailing (to Value label Leading)
btnMinus.trailingAnchor.constraint(equalTo: lblValue.leadingAnchor, constant: 0.0),
// buttons and Value label centered vertically
btnPlus.centerYAnchor.constraint(equalTo: g.centerYAnchor),
lblValue.centerYAnchor.constraint(equalTo: g.centerYAnchor),
btnMinus.centerYAnchor.constraint(equalTo: g.centerYAnchor),
btnMinus.widthAnchor.constraint(equalToConstant: 40.0),
btnPlus.widthAnchor.constraint(equalTo: btnMinus.widthAnchor),
lblValue.widthAnchor.constraint(equalTo: btnMinus.widthAnchor),
// Details label Trailing ("right edge")
lblDetails.trailingAnchor.constraint(equalTo: btnMinus.leadingAnchor, constant: -8.0),
])
btnMinus.addTarget(self, action: #selector(minusTap), for: .touchUpInside)
btnPlus.addTarget(self, action: #selector(plusTap), for: .touchUpInside)
}
}
class AdditionalGuestInformationVC: UIViewController, UITableViewDelegate, UITableViewDataSource {
let section = ["Male", "Female", "Total"]
var tableView: UITableView!
var myData: [[ItemStruct]] = []
func getData() -> [[ItemStruct]] {
let itemsName = [
["Shishu", "Baal", "Kishore", "Tarun", "Yuva", "Jyeshta"],
["Shishu", "Baalika", "Kishori", "Taruni", "Yuvati", "Jyeshtaa"],
]
let itemsDetails = [
["Below 5 Years - Pre-primary ", "5 to 11 Years - Primary School", "11 to 16 Years - Middle School", "17 to 25 Years - High School/College", "25 to 60 Years - Adults", ">60 Years - Senior citizen"],
["Below 5 Years - Pre-primary ", "5 to 11 Years - Primary School", "11 to 16 Years - Middle School", "17 to 25 Years - High School/College", "25 to 60 Years - Adults", ">60 Years - Senior citizen"],
]
var tmpArray: [[ItemStruct]] = []
// let's fill our data with names and details
for (names, details) in zip(itemsName, itemsDetails) {
var secData: [ItemStruct] = []
for i in 0..<names.count {
let d = ItemStruct(itemName: names[i], itemDetails: details[i], itemCount: 0)
secData.append(d)
}
tmpArray.append(secData)
}
return tmpArray
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemGreen
tableView = UITableView(frame: .zero, style: .insetGrouped)
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
// Top / Leading / Trailing inset by 20-points (so we can see the table frame)
tableView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
tableView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
tableView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
// let's leave some space at the bottom
tableView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -80.0),
])
// register the "input" cells
tableView.register(AdditionalGuestInforTVCell.self, forCellReuseIdentifier: "AdditionalGuestInforTVCell")
// register the "Total" cell
tableView.register(TotalCell.self, forCellReuseIdentifier: "totalCell")
tableView.dataSource = self
tableView.delegate = self
myData = getData()
}
// MARK: TableView Methods
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return self.section[section]
}
func numberOfSections(in tableView: UITableView) -> Int {
// add 1 for the Total section
return myData.count + 1
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
// if it's the last section, it's the Total section
if section == myData.count {
return 1
}
return myData[section].count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// if it's the last section, it's the Total section
if indexPath.section == myData.count {
let cell = tableView.dequeueReusableCell(withIdentifier: "totalCell", for: indexPath) as! TotalCell
// get the total of values from all data
var total: Int = 0
for i in 0..<self.myData.count {
total += self.myData[i].reduce(0) { $0 + $1.itemCount }
}
cell.fillData(total)
return cell
}
// it's not the last section
let cell = tableView.dequeueReusableCell(withIdentifier: "AdditionalGuestInforTVCell", for: indexPath) as! AdditionalGuestInforTVCell
cell.fillData(myData[indexPath.section][indexPath.row])
// add closure
cell.countChanged = { [weak self] aCell, aValue in
guard let self = self,
let thisCell = aCell as? AdditionalGuestInforTVCell,
let idxPath = self.tableView.indexPath(for: thisCell)
else { return }
// update the data
self.myData[idxPath.section][idxPath.row].itemCount = aValue
// reload the last section (the "Total" section)
self.tableView.reloadSections([self.myData.count], with: .none)
}
return cell
}
}
and it should look like this when running:
Just edit the code with three functions:: To show it like below image:
#objc func onMinusClick(_ sender: UIButton) {
let section = (sender.tag / 10) - 10
let row = sender.tag % 10
var value = Int(self.itemValue[section][row]) ?? 0
value -= 1
if value < 0 {
value = 0
}
self.itemValue[section][row] = "\(value)"
let indexPath = IndexPath(row: row, section: section)
if let cell = self.tableView.cellForRow(at: indexPath) as? AdditionalGuestInforTVCell {
cell.lblValue.text = self.itemValue[section][row]
}
calculateTotal()
}
#objc func onPlusClick(_ sender: UIButton) {
let section = (sender.tag / 10) - 10
let row = sender.tag % 10
var value = Int(self.itemValue[section][row]) ?? 0
value += 1
self.itemValue[section][row] = "\(value)"
let indexPath = IndexPath(row: row, section: section)
if let cell = self.tableView.cellForRow(at: indexPath) as? AdditionalGuestInforTVCell {
cell.lblValue.text = self.itemValue[section][row]
}
calculateTotal()
}
func calculateTotal() {
var total = 0
for i in 0..<itemValue.count {
for j in 0..<itemValue[i].count {
total += Int(itemValue[i][j]) ?? 0
}
}
let totalMembers = dicMember.count
let totalMembersLabel = UILabel(frame: CGRect(x: 0, y: 0, width: tableView.frame.width, height: 80))
totalMembersLabel.text = "Sankhya: \(totalMembers)\nGuest Sankhya: \(total) \nTotal Sankhya: \(totalMembers + total)"
totalMembersLabel.numberOfLines = 5
totalMembersLabel.textAlignment = .center
totalMembersLabel.font = UIFont.boldSystemFont(ofSize: 18)
tableView.tableHeaderView = totalMembersLabel
let label = UILabel(frame: CGRect(x: 0, y: 0, width: tableView.frame.width, height: 80))
label.text = " Guest Sankhya: \(total)"
label.textAlignment = .right
label.font = UIFont.systemFont(ofSize: 12)
tableView.tableFooterView = label
let combinedLabel = UILabel(frame: CGRect(x: 0, y: 0, width: tableView.frame.width, height: 80))
combinedLabel.text = "Guest Sankhya: \(total) "
combinedLabel.textAlignment = .center
combinedLabel.font = UIFont.boldSystemFont(ofSize: 18)
tableView.tableFooterView = combinedLabel
}
call calculateTotal() inside tableView for viewForHeaderInSection
//existing code
calculateTotal()
}

Call UICollectionViewDataSource method from UICollectionViewLayout subclass

I'm writing my own UICollectionViewLayout subclass to have full control over the layout and arrangement of my collection view cells. When computing the item sizes, I want the layout to start with the cells' fitting sizes, i.e. I want the cells to size themselves to fit their content initially and then modify their frames later based on that information.
To get the fitting sizes, I first ask the collection view's data source for all cells in the layout's prepare() method and layout all the cells using systemLayoutSizeFitting(_:).
let allCells = allIndedxPaths.compactMap {
collectionView.dataSource?.collectionView(collectionView, cellForItemAt: $0)
}
let allItemSizes = allCells.map { cell in
cell.systemLayoutSizeFitting(.init(width: contentSize.width, height: UIView.noIntrinsicMetric))
}
Visually, everything works as expected and I get exactly the result I want. However, every time the collection view is laid out, the following warning it printed to the console:
[CollectionView] An attempt to prepare a layout while a prepareLayout call was already in progress (i.e. reentrant call) has been ignored. Please file a bug.
So it appears to me that it is prohibited to call any data source method while the layout is being prepared.
Which brings me to my question:
How can I obtain information from the data source that I need to compute the layout from within my UICollectionViewLayout subclass?
(Or more specifically: How can I get the concrete cell sizes right before I compute the layout?)
Notes:
Since Apple's own UICollectionViewFlowLayout works with automatic cells sizes (estimatedItemSize), there must be a clean way to get this information.
Apple's Collection View Programming Guide states:
In a limited number of cases, the layout object might rely on information in the data source to position items. For example, a layout that displays items on a map might retrieve the map location of each item from the data source.
This also means that there must be a way to query the data source from the layout object.
Supplemental: Minimal Code Example
Below is a (not so) minimal code for the layout that I'm trying to achieve.
class SelfSizingTagListLayout: UICollectionViewLayout {
private var contentSize: CGSize = .zero
private var frames: [IndexPath: CGRect] = [:]
private var cachedAttributes: [IndexPath: UICollectionViewLayoutAttributes] = [:]
let interItemSpacing: CGFloat = 8
override func prepare() {
guard let collectionView else {
return
}
contentSize = .init(width: collectionView.bounds.width, height: 0)
let itemCount = collectionView.numberOfItems(inSection: 0)
let allIndedxPaths = (0..<itemCount).map { IndexPath(item: $0, section: 0) }
let allCells = allIndedxPaths.compactMap {
collectionView.dataSource?.collectionView(collectionView, cellForItemAt: $0)
}
let allItemSizes = allCells.map { cell in
cell.systemLayoutSizeFitting(.init(width: contentSize.width, height: UIView.noIntrinsicMetric))
}
frames = computeFrames(for: allItemSizes)
cachedAttributes = [:]
for frame in frames {
let attributes = UICollectionViewLayoutAttributes(forCellWith: frame.key)
attributes.frame = frame.value
cachedAttributes[frame.key] = attributes
}
contentSize.height = frames.map(\.value.maxY).max() ?? 0
}
private func computeFrames(for itemSizes: [CGSize]) -> [IndexPath: CGRect] {
let contentWidth = collectionView!.bounds.width
let rowHeight = itemSizes.map(\.height).max() ?? 0
var row: Int = 0
var x: CGFloat = 0
var y: CGFloat {
CGFloat(row) * (rowHeight + interItemSpacing)
}
var item: Int = 0
var frames: [IndexPath: CGRect] = [:]
for itemSize in itemSizes {
if x + itemSize.width > contentWidth {
row += 1
x = 0
}
let frame = CGRect(origin: .init(x: x, y: y), size: itemSize)
frames[IndexPath(item: item, section: 0)] = frame
item += 1
x += itemSize.width + interItemSpacing
}
return frames
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
cachedAttributes.map(\.value)
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
cachedAttributes[indexPath]
}
override var collectionViewContentSize: CGSize {
contentSize
}
}
As discussed in comments, it's generally not a good idea to "ignore" the warning / error messages, even if the output is what we want. Just because it works now doesn't mean it will work in the future.
So... one way to avoid that...
To reduce duplicate code (that has to be changed in multiple places when the cell / data changes), we'll start by assuming we've set up our cell to handle its content.
So, in cellForItemAt, instead of a bunch of:
cell.labelOne.text = ...
cell.labelTwo.text = ...
cell.imageView.image = ...
etc.
it might look like this:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "c", for: indexPath) as! SomeCVCell
cell.fillData(myData[indexPath.item])
return cell
}
Since we get the (i.e. reentrant call) error if cellForItemAt is called before the first layout has completed, let's add a protocol:
protocol MyLayoutDelegate {
func cellFor(_ indexPath: IndexPath) -> UICollectionViewCell
}
and implement it almost the same way:
func cellFor(_ indexPath: IndexPath) -> UICollectionViewCell {
let cell = SomeCVCell()
cell.fillData(myData[indexPath.item])
return cell
}
Next we add a delegate property:
class SelfSizingTagListLayout: UICollectionViewLayout {
var myLayoutDelegate: MyLayoutDelegate?
in the controller class:
// create collection view layout
let sstlLayout = SelfSizingTagListLayout()
// set custom delegate
sstlLayout.myLayoutDelegate = self
and then, inside prepare():
// instead of this...
//let allCells = allIndedxPaths.compactMap {
// collectionView.dataSource?.collectionView(collectionView, cellForItemAt: $0)
//}
// let's get the cells via our custom delegate
let allCells = allIndedxPaths.compactMap {
myLayoutDelegate?.cellFor($0)
}
Here's a complete example to demonstrate...
Simple single-label cell
class SomeCVCell: UICollectionViewCell {
let label: UILabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
label.textColor = .black
label.textAlignment = .center
label.font = .systemFont(ofSize: 16.0)
label.translatesAutoresizingMaskIntoConstraints = false
label.numberOfLines = 0
contentView.addSubview(label)
let g = contentView.layoutMarginsGuide
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 4.0),
label.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -4.0),
label.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
label.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
])
contentView.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
contentView.layer.borderColor = UIColor.red.cgColor
contentView.layer.borderWidth = 1
contentView.layer.cornerRadius = 6
}
public func fillData(_ d: String) {
label.text = "#" + d
}
}
Protocol
protocol MyLayoutDelegate: AnyObject {
func cellFor(_ indexPath: IndexPath) -> UICollectionViewCell
}
Example controller - collection view and button to cycle through data-sets
class CustomLayoutVC: UIViewController,
UICollectionViewDataSource, UICollectionViewDelegate,
MyLayoutDelegate {
var collectionView: UICollectionView!
// data for cells will be filled by code
var myData: [String] = []
var sampleStringSets: [[String]] = []
var samplesIDX: Int = 0
// button tap will cycle through the sample data sets
#objc func btnTap(_ sender: Any?) {
samplesIDX += 1
myData = sampleStringSets[samplesIDX % sampleStringSets.count]
collectionView.reloadData()
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
// Next Data Set button
let btn: UIButton = {
let v = UIButton()
v.setTitle("Get Next Data Set", for: [])
v.setTitleColor(.white, for: .normal)
v.setTitleColor(.lightGray, for: .highlighted)
v.backgroundColor = .systemBlue
v.layer.cornerRadius = 6
v.addTarget(self, action: #selector(btnTap(_:)), for: .touchUpInside)
return v
}()
// create collection view layout
let sstlLayout = SelfSizingTagListLayout()
// set custom delegate
sstlLayout.myLayoutDelegate = self
// create collection view
collectionView = UICollectionView(frame: .zero, collectionViewLayout: sstlLayout)
btn.translatesAutoresizingMaskIntoConstraints = false
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(btn)
view.addSubview(collectionView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
// put button near top
btn.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
btn.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
btn.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
// collection view below button, inset 20-points on each side
collectionView.topAnchor.constraint(equalTo: btn.bottomAnchor, constant: 40.0),
collectionView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
collectionView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
collectionView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),
])
// usual collection view stuff
collectionView.register(SomeCVCell.self, forCellWithReuseIdentifier: "c")
collectionView.dataSource = self
collectionView.delegate = self
// get sample data
sampleStringSets = SampleTags().samples()
// set the first example data set
myData = sampleStringSets[samplesIDX % sampleStringSets.count]
// background color for the collection view
// so we can see its frame
collectionView.backgroundColor = .systemYellow
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return myData.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "c", for: indexPath) as! SomeCVCell
cell.fillData(myData[indexPath.item])
return cell
}
func cellFor(_ indexPath: IndexPath) -> UICollectionViewCell {
let cell = SomeCVCell()
cell.fillData(myData[indexPath.item])
return cell
}
}
Custom Layout class
class SelfSizingTagListLayout: UICollectionViewLayout {
weak var myLayoutDelegate: MyLayoutDelegate?
private var contentSize: CGSize = .zero
private var frames: [IndexPath: CGRect] = [:]
private var cachedAttributes: [IndexPath: UICollectionViewLayoutAttributes] = [:]
let interItemSpacing: CGFloat = 8
override func prepare() {
guard let collectionView else {
return
}
contentSize = .init(width: collectionView.bounds.width, height: 0)
let itemCount = collectionView.numberOfItems(inSection: 0)
let allIndedxPaths = (0..<itemCount).map { IndexPath(item: $0, section: 0) }
// instead of this...
//let allCells = allIndedxPaths.compactMap {
// collectionView.dataSource?.collectionView(collectionView, cellForItemAt: $0)
//}
// let's get the cells via our custom delegate
let allCells = allIndedxPaths.compactMap {
myLayoutDelegate?.cellFor($0)
}
let allItemSizes = allCells.map { cell in
cell.systemLayoutSizeFitting(.init(width: contentSize.width, height: UIView.noIntrinsicMetric))
}
frames = computeFrames(for: allItemSizes)
cachedAttributes = [:]
for frame in frames {
let attributes = UICollectionViewLayoutAttributes(forCellWith: frame.key)
attributes.frame = frame.value
cachedAttributes[frame.key] = attributes
}
contentSize.height = frames.map(\.value.maxY).max() ?? 0
}
private func computeFrames(for itemSizes: [CGSize]) -> [IndexPath: CGRect] {
let contentWidth = collectionView!.bounds.width
let rowHeight = itemSizes.map(\.height).max() ?? 0
var row: Int = 0
var x: CGFloat = 0
var y: CGFloat {
CGFloat(row) * (rowHeight + interItemSpacing)
}
var item: Int = 0
var frames: [IndexPath: CGRect] = [:]
for itemSize in itemSizes {
if x + itemSize.width > contentWidth {
row += 1
x = 0
}
let frame = CGRect(origin: .init(x: x, y: y), size: itemSize)
frames[IndexPath(item: item, section: 0)] = frame
item += 1
x += itemSize.width + interItemSpacing
}
return frames
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
cachedAttributes.map(\.value)
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
cachedAttributes[indexPath]
}
override var collectionViewContentSize: CGSize {
contentSize
}
}
Just some sample tags to use as data
class SampleTags: NSData {
func samples() -> [[String]] {
let tmp: [[String]] = [
[
".htaccess",
".net",
"ajax",
"algorithm",
"amazon-web-services",
"android-layout",
"android-studio",
"android",
"angular",
"angularjs",
"apache-spark",
],
[
"apache",
"api",
"arrays",
"asp.net-core",
"asp.net-mvc",
"asp.net",
"azure",
"bash",
"c",
"c#",
"c++",
"class",
"codeigniter",
"cordova",
"css",
"csv",
"dart",
"database",
"dataframe",
"date",
"datetime",
"dictionary",
"django",
"docker",
"eclipse",
"email",
"entity-framework",
"excel",
"express",
],
[
"facebook",
"file",
"firebase",
"flutter",
"for-loop",
"forms",
"function",
"git",
"go",
"google-chrome",
"google-maps",
"hibernate",
"html",
"http",
"image",
"ios",
"iphone",
"java",
"javascript",
"jquery",
"json",
"kotlin",
"laravel",
"linq",
"linux",
"list",
"loops",
"macos",
"matlab",
"matplotlib",
"maven",
"mongodb",
"multithreading",
"mysql",
"node.js",
"numpy",
"object",
"objective-c",
"oop",
"opencv",
"oracle",
"pandas",
"performance",
"perl",
"php",
"postgresql",
"powershell",
"python-2.7",
"python-3.x",
"python",
"qt",
"r",
"react-native",
"reactjs",
"regex",
"rest",
"ruby-on-rails-3",
"ruby-on-rails",
"ruby",
"scala",
"selenium",
"shell",
"sockets",
"sorting",
"spring-boot",
"spring-mvc",
"spring",
"sql-server",
"sql",
],
]
return tmp
}
}
Looks like this when run:
The answer is:
You can't do it.
I asked an Apple engineer this question and they said:
It is definitely not supported to have your layout directly fetch cells from the data source.
So the only way to get information from the data source from within a collection view layout object is to provide this information in another (custom) method and query this method from both the layout object and the data source ( cellForItemAt method).
DonMag shows nicely how to do that in this answer. (Please make sure to also read the comments.)

How can I make dynamic cell width for default layout with horizontal scrolling and equal cell space with leading Swift + iOS? [duplicate]

I have a dynamic collectionView, and essentially the spacing between cells needs to be the same regardless the width of the cell.
Found similar answers here and on the internet, but all were for vertical scrolling collectionViews. So, I went on and tried to work further on one of those answers to achieve what I want, with no much luck.
Currently, my collectionView has the same spacing between cells, but after each cell, it moves to the next row, although I'm not changing or manipulating the y offset of the attributes. Also, not all cells are visible.
Please, can you point out what I'm doing wrong? Thanks.
The subclass of UICollectionViewFlowLayout that I'm using is:
class TagsLayout: UICollectionViewFlowLayout {
let cellSpacing: CGFloat = 20
override init(){
super.init()
scrollDirection = .horizontal
}
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)!
self.scrollDirection = .horizontal
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let attributes = super.layoutAttributesForElements(in: rect) else {
return nil
}
guard let attributesToReturn = attributes.map( { $0.copy() }) as? [UICollectionViewLayoutAttributes] else {
return nil
}
var leftMargin = sectionInset.left
var maxX: CGFloat = -1.0
attributesToReturn.forEach { layoutAttribute in
if layoutAttribute.frame.origin.x >= maxX {
leftMargin = sectionInset.left
}
layoutAttribute.frame.origin.x = leftMargin
leftMargin += layoutAttribute.frame.width + cellSpacing
maxX = max(layoutAttribute.frame.maxX , maxX)
}
return attributesToReturn
}
}
As I said in my comment, you are using code for a "left-aligned vertical scrolling" collection view.
A horizontal scrolling collection view lays out the cells like this:
your code is calculating a new origin.x for each cell in sequence, resulting in this:
You could modify your custom flow layout to keep track of a maxX for each "row" ... but, if you have a lot of cells as soon as you scroll so the first few "columns" are out-of-view, those cells will no longer be factored into the layout.
So, you could attempt to "pre-calculated" the frame widths and x-origins of all your cells, and get close to your goal:
Two more issues though...
First, assuming your cells contain longer strings than shown in these images, the collection view doesn't do a good job of figuring out which cells actually need to be shown. That is, the collection view will use the estimated items size to decide if a cell will need to be rendered. If the modification to the cells origin.x values would not fall within the expected range, certain cells will not be rendered because the collection view won't ask for them.
Second, if you have varying-width tags, you could end up with something like this:
and rotated to landscape for emphasis (the top row actually goes all the way to 24):
You may want to re-think your approach and either go with a vertical-scrolling left-aligned collection view, or a horizontal-scrolling collection view with equal-width cells, or some other approach (such as a normal scroll view with subviews laid-out via your own code).
I did create classes using the "pre-calculate" approach -- here they are if you want to give it a try.
Simple cell with a label:
class TagCell: UICollectionViewCell {
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(label)
let g = contentView.layoutMarginsGuide
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: g.topAnchor, constant: 4.0),
label.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0),
label.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -8.0),
label.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -4.0),
])
// default (unselected) appearance
contentView.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
label.textColor = .black
// let's round the corners so it looks nice
contentView.layer.cornerRadius = 12
}
}
Modified custom flow layout:
class TagsLayout: UICollectionViewFlowLayout {
var cachedFrames: [[CGRect]] = []
var numRows: Int = 3
let cellSpacing: CGFloat = 20
override init(){
super.init()
commonInit()
}
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)!
commonInit()
}
func commonInit() {
scrollDirection = .horizontal
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
// guard let attributes = super.layoutAttributesForElements(in: rect) else {
// return nil
// }
// we want to force the collection view to ask for the attributes for ALL the cells
// instead of the cells in the rect
var r: CGRect = rect
// we could probably get and use the max-width from the cachedFrames array...
// but let's just set it to a very large value for now
r.size.width = 50000
guard let attributes = super.layoutAttributesForElements(in: r) else {
return nil
}
guard let attributesToReturn = attributes.map( { $0.copy() }) as? [UICollectionViewLayoutAttributes] else {
return nil
}
attributesToReturn.forEach { layoutAttribute in
let thisRow: Int = layoutAttribute.indexPath.item % numRows
let thisCol: Int = layoutAttribute.indexPath.item / numRows
layoutAttribute.frame.origin.x = cachedFrames[thisRow][thisCol].origin.x
}
return attributesToReturn
}
}
Example controller class with generated tag strings:
class HorizontalTagColViewVC: UIViewController {
var collectionView: UICollectionView!
var myData: [String] = []
// number of cells that will fit vertically in the collection view
let numRows: Int = 3
override func viewDidLoad() {
super.viewDidLoad()
// let's generate some rows of "tags"
// we're using 3 rows for this example
for i in 0...28 {
switch i % numRows {
case 0:
// top row will have long tag strings
myData.append("A long tag name \(i)")
case 1:
// 2nd row will have short tag strings
myData.append("Tag \(i)")
default:
// 3nd row will have numeric strings
myData.append("\(i)")
}
}
// now we'll pre-calculate the tag-cell widths
let szCell = TagCell()
let fitSize = CGSize(width: 1000, height: 50)
var calcedFrames: [[CGRect]] = Array(repeating: [], count: numRows)
for i in 0..<myData.count {
szCell.label.text = myData[i]
let sz = szCell.systemLayoutSizeFitting(fitSize, withHorizontalFittingPriority: .defaultLow, verticalFittingPriority: .required)
let r = CGRect(origin: .zero, size: sz)
calcedFrames[i % numRows].append(r)
}
// loop through each "row" setting the origin.x to the
// previous cell's origin.x + width + 20
for row in 0..<numRows {
for col in 1..<calcedFrames[row].count {
var thisRect = calcedFrames[row][col]
let prevRect = calcedFrames[row][col - 1]
thisRect.origin.x += prevRect.maxX + 20.0
calcedFrames[row][col] = thisRect
}
}
let fl = TagsLayout()
// for horizontal flow, this is becomes the minimum-inter-line spacing
fl.minimumInteritemSpacing = 20
// we need this so the last cell does not get clipped
fl.minimumLineSpacing = 20
// a reasonalbe estimated size
fl.estimatedItemSize = CGSize(width: 120, height: 50)
// set the number of rows in our custom layout
fl.numRows = numRows
// set our calculated frames in our custom layout
fl.cachedFrames = calcedFrames
collectionView = UICollectionView(frame: .zero, collectionViewLayout: fl)
// so we can see the collection view frame
collectionView.backgroundColor = .cyan
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
collectionView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
collectionView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
collectionView.heightAnchor.constraint(equalToConstant: 180.0),
])
collectionView.register(TagCell.self, forCellWithReuseIdentifier: "cell")
collectionView.dataSource = self
collectionView.delegate = self
}
}
extension HorizontalTagColViewVC: UICollectionViewDataSource, UICollectionViewDelegate {
func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return myData.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let c = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! TagCell
c.label.text = myData[indexPath.item]
return c
}
}
Note that this is Example Code Only!!! It has not been tested and may or may not fit your needs.

How to make a horizontal UICollectionView have the same spacing between dynamic cells

I have a dynamic collectionView, and essentially the spacing between cells needs to be the same regardless the width of the cell.
Found similar answers here and on the internet, but all were for vertical scrolling collectionViews. So, I went on and tried to work further on one of those answers to achieve what I want, with no much luck.
Currently, my collectionView has the same spacing between cells, but after each cell, it moves to the next row, although I'm not changing or manipulating the y offset of the attributes. Also, not all cells are visible.
Please, can you point out what I'm doing wrong? Thanks.
The subclass of UICollectionViewFlowLayout that I'm using is:
class TagsLayout: UICollectionViewFlowLayout {
let cellSpacing: CGFloat = 20
override init(){
super.init()
scrollDirection = .horizontal
}
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)!
self.scrollDirection = .horizontal
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let attributes = super.layoutAttributesForElements(in: rect) else {
return nil
}
guard let attributesToReturn = attributes.map( { $0.copy() }) as? [UICollectionViewLayoutAttributes] else {
return nil
}
var leftMargin = sectionInset.left
var maxX: CGFloat = -1.0
attributesToReturn.forEach { layoutAttribute in
if layoutAttribute.frame.origin.x >= maxX {
leftMargin = sectionInset.left
}
layoutAttribute.frame.origin.x = leftMargin
leftMargin += layoutAttribute.frame.width + cellSpacing
maxX = max(layoutAttribute.frame.maxX , maxX)
}
return attributesToReturn
}
}
As I said in my comment, you are using code for a "left-aligned vertical scrolling" collection view.
A horizontal scrolling collection view lays out the cells like this:
your code is calculating a new origin.x for each cell in sequence, resulting in this:
You could modify your custom flow layout to keep track of a maxX for each "row" ... but, if you have a lot of cells as soon as you scroll so the first few "columns" are out-of-view, those cells will no longer be factored into the layout.
So, you could attempt to "pre-calculated" the frame widths and x-origins of all your cells, and get close to your goal:
Two more issues though...
First, assuming your cells contain longer strings than shown in these images, the collection view doesn't do a good job of figuring out which cells actually need to be shown. That is, the collection view will use the estimated items size to decide if a cell will need to be rendered. If the modification to the cells origin.x values would not fall within the expected range, certain cells will not be rendered because the collection view won't ask for them.
Second, if you have varying-width tags, you could end up with something like this:
and rotated to landscape for emphasis (the top row actually goes all the way to 24):
You may want to re-think your approach and either go with a vertical-scrolling left-aligned collection view, or a horizontal-scrolling collection view with equal-width cells, or some other approach (such as a normal scroll view with subviews laid-out via your own code).
I did create classes using the "pre-calculate" approach -- here they are if you want to give it a try.
Simple cell with a label:
class TagCell: UICollectionViewCell {
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
label.textAlignment = .center
label.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(label)
let g = contentView.layoutMarginsGuide
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: g.topAnchor, constant: 4.0),
label.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0),
label.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -8.0),
label.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -4.0),
])
// default (unselected) appearance
contentView.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
label.textColor = .black
// let's round the corners so it looks nice
contentView.layer.cornerRadius = 12
}
}
Modified custom flow layout:
class TagsLayout: UICollectionViewFlowLayout {
var cachedFrames: [[CGRect]] = []
var numRows: Int = 3
let cellSpacing: CGFloat = 20
override init(){
super.init()
commonInit()
}
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)!
commonInit()
}
func commonInit() {
scrollDirection = .horizontal
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
// guard let attributes = super.layoutAttributesForElements(in: rect) else {
// return nil
// }
// we want to force the collection view to ask for the attributes for ALL the cells
// instead of the cells in the rect
var r: CGRect = rect
// we could probably get and use the max-width from the cachedFrames array...
// but let's just set it to a very large value for now
r.size.width = 50000
guard let attributes = super.layoutAttributesForElements(in: r) else {
return nil
}
guard let attributesToReturn = attributes.map( { $0.copy() }) as? [UICollectionViewLayoutAttributes] else {
return nil
}
attributesToReturn.forEach { layoutAttribute in
let thisRow: Int = layoutAttribute.indexPath.item % numRows
let thisCol: Int = layoutAttribute.indexPath.item / numRows
layoutAttribute.frame.origin.x = cachedFrames[thisRow][thisCol].origin.x
}
return attributesToReturn
}
}
Example controller class with generated tag strings:
class HorizontalTagColViewVC: UIViewController {
var collectionView: UICollectionView!
var myData: [String] = []
// number of cells that will fit vertically in the collection view
let numRows: Int = 3
override func viewDidLoad() {
super.viewDidLoad()
// let's generate some rows of "tags"
// we're using 3 rows for this example
for i in 0...28 {
switch i % numRows {
case 0:
// top row will have long tag strings
myData.append("A long tag name \(i)")
case 1:
// 2nd row will have short tag strings
myData.append("Tag \(i)")
default:
// 3nd row will have numeric strings
myData.append("\(i)")
}
}
// now we'll pre-calculate the tag-cell widths
let szCell = TagCell()
let fitSize = CGSize(width: 1000, height: 50)
var calcedFrames: [[CGRect]] = Array(repeating: [], count: numRows)
for i in 0..<myData.count {
szCell.label.text = myData[i]
let sz = szCell.systemLayoutSizeFitting(fitSize, withHorizontalFittingPriority: .defaultLow, verticalFittingPriority: .required)
let r = CGRect(origin: .zero, size: sz)
calcedFrames[i % numRows].append(r)
}
// loop through each "row" setting the origin.x to the
// previous cell's origin.x + width + 20
for row in 0..<numRows {
for col in 1..<calcedFrames[row].count {
var thisRect = calcedFrames[row][col]
let prevRect = calcedFrames[row][col - 1]
thisRect.origin.x += prevRect.maxX + 20.0
calcedFrames[row][col] = thisRect
}
}
let fl = TagsLayout()
// for horizontal flow, this is becomes the minimum-inter-line spacing
fl.minimumInteritemSpacing = 20
// we need this so the last cell does not get clipped
fl.minimumLineSpacing = 20
// a reasonalbe estimated size
fl.estimatedItemSize = CGSize(width: 120, height: 50)
// set the number of rows in our custom layout
fl.numRows = numRows
// set our calculated frames in our custom layout
fl.cachedFrames = calcedFrames
collectionView = UICollectionView(frame: .zero, collectionViewLayout: fl)
// so we can see the collection view frame
collectionView.backgroundColor = .cyan
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
collectionView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
collectionView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
collectionView.heightAnchor.constraint(equalToConstant: 180.0),
])
collectionView.register(TagCell.self, forCellWithReuseIdentifier: "cell")
collectionView.dataSource = self
collectionView.delegate = self
}
}
extension HorizontalTagColViewVC: UICollectionViewDataSource, UICollectionViewDelegate {
func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return myData.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let c = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! TagCell
c.label.text = myData[indexPath.item]
return c
}
}
Note that this is Example Code Only!!! It has not been tested and may or may not fit your needs.

Reusable UITableHeaderFooterView Loose ImageView Orientation State

I have an expandable UITableView with specific cell, header and footer heights. When user taps a header, cells are started to shown below it (section expand). When user taps again, section collapse.
My problem is that when user taps the header, title becomes green and arrows (UIImageView) orient change. When I don't use dequeueReusableHeaderFooterView, everything works perfect but when I reuse the header, green title and arrow orientation doesn't look as expected on tap or scroll.
In below picture, New York's title color looks ok but arrow orientation is wrong. Also Manhattan, header is expanded but doesn't get green color and right UIImageView orientation.
P.S: I know this has been asked lots of times but I can't figure out which one is the proper way.
Header View Class:
protocol ExpandableHeaderViewDelegate {
func toggleSection(header: DistrictTableViewHeader, section: Int)
}
class DistrictTableViewHeader: UITableViewHeaderFooterView {
var delegate: ExpandableHeaderViewDelegate?
var section: Int!
let nameLabel: UILabel = {
let l = UILabel()
l.textColor = Color.DistrictsPage.headerTextColor
return l
}()
private let arrowImage: UIImageView = {
let i = UIImageView()
let image = UIImage(named: "ileri")?.withRenderingMode(UIImage.RenderingMode.alwaysTemplate)
i.image = image
i.contentMode = .scaleAspectFit
return i
}()
var isColapsed: Bool!{
didSet{
layoutSubviews()
}
}
override init(reuseIdentifier: String?) {
super.init(reuseIdentifier: reuseIdentifier)
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(selectHeaderAction)))
nameLabel.translatesAutoresizingMaskIntoConstraints = false
nameLabel.font = UIFont.systemFont(ofSize: 22)
nameLabel.textColor = Color.DistrictsPage.headerTextColor
contentView.addSubview(nameLabel)
nameLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor).isActive = true
nameLabel.leftAnchor.constraint(equalTo: contentView.leftAnchor, constant: 15).isActive = true
arrowImage.tintColor = UIColor(red:0.32, green:0.36, blue:0.36, alpha:1.0)
arrowImage.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(arrowImage)
arrowImage.centerYAnchor.constraint(equalTo: contentView.centerYAnchor).isActive = true
arrowImage.rightAnchor.constraint(equalTo: contentView.rightAnchor, constant: -20).isActive = true
arrowImage.widthAnchor.constraint(equalToConstant: 20).isActive = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
#objc func selectHeaderAction(gestureRecognizer: UITapGestureRecognizer) {
let cell = gestureRecognizer.view as! DistrictTableViewHeader
self.isColapsed = !isColapsed
if(!isColapsed){
let degrees : Double = 90 //the value in degrees
UIView.animate(withDuration: 0.5) { [weak self] in
self?.nameLabel.textColor = Color.Common.garantiLightGreen
self?.arrowImage.tintColor = Color.Common.garantiLightGreen
self?.arrowImage.rotate(CGFloat(degrees * .pi/180))
self?.contentView.backgroundColor = UIColor(red:0.97, green:0.97, blue:0.97, alpha:1.0)
}
}else{
let degrees : Double = 0 //the value in degrees
UIView.animate(withDuration: 0.5) { [weak self] in
self?.nameLabel.textColor = Color.DistrictsPage.headerTextColor
self?.arrowImage.tintColor = UIColor.black
self?.arrowImage.rotate(CGFloat(degrees * .pi/180))
self?.contentView.backgroundColor = UIColor.white
}
}
delegate?.toggleSection(header: self, section: cell.section)
}
func customInit(title: String, section: Int, delegate: ExpandableHeaderViewDelegate) {
self.nameLabel.text = title
self.nameLabel.accessibilityIdentifier = title
self.section = section
self.delegate = delegate
}
override func layoutSubviews() {
super.layoutSubviews()
self.contentView.backgroundColor = UIColor.white
}
}
How I initialize the header:
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return 60
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let header: DistrictTableViewHeader = tableView.dequeueReusableHeaderFooterView(withIdentifier: headerId) as! DistrictTableViewHeader
//let header = DistrictTableViewHeader()
header.isColapsed = !self.cities[section].isExpanded
header.customInit(title: self.cities[section].name, section: section, delegate: self)
return header
}
How I expand/collapse:
func toggleSection(header: DistrictTableViewHeader, section: Int) {
self.cities[section].isExpanded = !self.cities[section].isExpanded
let contentOffset = self.tableView.contentOffset
self.tableView.reloadData()
}
EDIT: For the UITableView gurus, I added also a sample project :)
Sample Project Link:
https://github.com/emreond/tableViewLayoutIssue
After you dequeue a reusable view you must reset all changes that could have happened to it.
In this case you are setting header.isColapsed but that does not reset all the view's state that it needs to. The view state changing code in selectHeaderAction needs to also be called when the view is reused.
It will take a little refactoring to do right. Change the isColapse setter to do more:
var isColapsed: Bool!{
didSet{
if(!isColapsed){
let degrees : Double = 90 //the value in degrees
self?.nameLabel.textColor = Color.Common.garantiLightGreen
self?.arrowImage.tintColor = Color.Common.garantiLightGreen
self?.arrowImage.rotate(CGFloat(degrees * .pi/180))
self?.contentView.backgroundColor = UIColor(red:0.97, green:0.97, blue:0.97, alpha:1.0)
}else{
let degrees : Double = 0 //the value in degrees
self?.nameLabel.textColor = Color.DistrictsPage.headerTextColor
self?.arrowImage.tintColor = UIColor.black
self?.arrowImage.rotate(CGFloat(degrees * .pi/180))
self?.contentView.backgroundColor = UIColor.white
}
layoutSubviews()
}
}
And make the tapper gesture do less:
#objc func selectHeaderAction(gestureRecognizer: UITapGestureRecognizer) {
let cell = gestureRecognizer.view as! DistrictTableViewHeader
UIView.animate(withDuration: 0.5) { [weak self] in
self.isColapsed = !isColapsed
}
delegate?.toggleSection(header: self, section: cell.section)
}
I think that should fix it. Tell me if it works.
You are doing rotation wrong. Adding a CAAnimation is cool, but you can't easily cancel it when the view is reused. Instead just change the transform property and let the UIView.animate block deal with the animation at a higher level.
func rotate(_ toValue: CGFloat) {
self.transform = CGAffineTransform.init(rotationAngle: toValue)
}
Next you are reloading the table which is canceling all the animation that you want to do.
func toggleSection(header: DistrictTableViewHeader, section: Int) {
self.cities[section].isExpanded = !self.cities[section].isExpanded
let count = self.cities[section].districts.count
let indexPaths:[IndexPath] = (0..<count).map{ IndexPath.init(row: $0, section: section) }
if self.cities[section].isExpanded {
self.tableView.insertRows(at: indexPaths, with: .automatic);
}else{
self.tableView.deleteRows(at: indexPaths, with: .automatic);
}
}

Resources