UITableView Custom Cell is duplicating UIButton on scroll - ios

I have a UITableViewController that contains a number of custom UITableViewCells types.
One of these types simply is a cell containing a UIStackView that itself contains one or more UIButton's.
When scrolling off screen and back on, the buttons are being added again. This happens on each scroll event.
Pre Scroll Image
Post Scroll Image
I understand that as a cell is reused for performance potentially what is happening is my setup code in cellForRowAt where I configure a cell is being executed again.
Hence it is adding 3 buttons from the data source, to the cell, which already contains buttons from the last render.
I do not understand how I can clear this up and prevent this behaviour however and would very much appreciated someone offering an insight as I am lost.
I have been able to prepare a small app that recreates this as I cannot share my current project as it is closed source.
I apologise for the mountain of code below, this is however the minimum required to simply drop into a project and recreate.
class TableViewController: UITableViewController {
let textCellId = "textCellId"
let buttonCellId = "buttonCellId"
// MARK: - Mock Data Source
let cellContent = [
Message(type: .buttonGroup, buttonGroup: [
MessageButton(label: "Button #1"),
MessageButton(label: "Button #2"),
MessageButton(label: "Button #3")
]),
Message(type: .text, text: "A"),
Message(type: .text, text: "B"),
Message(type: .text, text: "C"),
Message(type: .text, text: "D"),
Message(type: .text, text: "E"),
Message(type: .text, text: "F"),
Message(type: .text, text: "G"),
Message(type: .text, text: "H"),
Message(type: .text, text: "I"),
Message(type: .text, text: "J"),
Message(type: .text, text: "K"),
Message(type: .text, text: "L"),
Message(type: .text, text: "M"),
Message(type: .text, text: "N"),
Message(type: .text, text: "O"),
Message(type: .text, text: "P"),
Message(type: .text, text: "Q"),
Message(type: .text, text: "R"),
Message(type: .text, text: "S"),
]
override func viewDidLoad() {
super.viewDidLoad()
registerCells()
configureTableView()
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return cellContent.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let item = cellContent[indexPath.row]
switch indexPath.row {
case 0:
let cell = tableView.dequeueReusableCell(withIdentifier: buttonCellId, for: indexPath) as! ButtonCell
cell.buttonGroupContent = item.buttonGroup
return cell
default:
let cell = tableView.dequeueReusableCell(withIdentifier: textCellId, for: indexPath) as! TextCell
cell.textLabel?.text = item.text
return cell
}
}
}
// MARK: - Misc TableView Setup
extension TableViewController {
fileprivate func registerCells() {
tableView.register(TextCell.self, forCellReuseIdentifier: textCellId)
tableView.register(ButtonCell.self, forCellReuseIdentifier: buttonCellId)
}
fileprivate func configureTableView() {
tableView.allowsSelection = false
tableView.alwaysBounceVertical = false
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 200
tableView.separatorStyle = .none
tableView.backgroundColor = UIColor.lightGray
tableView.contentInset = UIEdgeInsets(top: 24, left: 0, bottom: 50, right: 0)
tableView.tableFooterView = UIView()
}
}
// MARK: - Cell Types
class TextCell: UITableViewCell {
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class ButtonCell: UITableViewCell {
var buttonGroupContent: [MessageButton]? {
didSet {
anchorSubViews()
}
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
fileprivate var button: UIButton {
let button = UIButton(type: .custom)
button.translatesAutoresizingMaskIntoConstraints = false
button.backgroundColor = UIColor.darkGray
button.contentEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5)
button.layer.cornerRadius = 5
button.layer.masksToBounds = true
return button
}
fileprivate let buttonGroupStackView: UIStackView = {
let stackView = UIStackView()
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.distribution = .fillEqually
stackView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
stackView.isLayoutMarginsRelativeArrangement = true
stackView.spacing = UIStackView.spacingUseSystem
return stackView
}()
}
extension ButtonCell {
fileprivate func anchorSubViews() {
guard let buttons = buttonGroupContent?.enumerated() else { return }
for (index, b) in buttons {
let btn = button
btn.setTitle(b.label, for: .normal)
btn.frame = CGRect(x: 0, y: 0, width: 200, height: 40)
btn.tag = index
buttonGroupStackView.addArrangedSubview(btn)
}
addSubview(buttonGroupStackView)
NSLayoutConstraint.activate([
buttonGroupStackView.topAnchor.constraint(equalTo: topAnchor),
buttonGroupStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
buttonGroupStackView.bottomAnchor.constraint(equalTo: bottomAnchor),
buttonGroupStackView.trailingAnchor.constraint(equalTo: trailingAnchor)
])
}
}
// MARK: - Misc for setup
struct MessageButton {
let label: String
}
enum MessageType {
case text, buttonGroup
}
struct Message {
let type: MessageType
let text: String?
let buttonGroup: [MessageButton]?
init(type: MessageType, text: String? = nil, buttonGroup: [MessageButton]? = nil) {
self.type = type
self.text = text
self.buttonGroup = buttonGroup
}
}

Because cells are reusable, content stays. So your old buttons are still in your stack view and you're adding next buttons every time.
To fix this, before you add new buttons to UIStackView remove old buttons
extension ButtonCell {
fileprivate func anchorSubViews() {
...
for case let button as UIButton in buttonGroupStackView.subviews {
button.removeFromSuperview()
}
for (index, b) in buttons {
...
buttonGroupStackView.addArrangedSubview(btn)
}
...
}
}

Make your button and buttonGroupStackView properties optional and weak. The addSubview method on will retain a strong reference to it's subview. So it never gets removed. And override prepareForReuse() to do whatever cleanup necessary and make sure the stackview has been removed from the cell. Here's how you could do it:
class ButtonCell: UITableViewCell {
var buttonGroupContent: [MessageButton]? {
didSet {
anchorSubViews()
}
}
fileprivate weak var buttonGroupStackView: UIStackView?
// MARK: - Initialization
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
self.initialSetup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
self.initialSetup()
}
private func initialSetup() -> Void {
let stackView = UIStackView()
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.distribution = .fillEqually
stackView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
stackView.isLayoutMarginsRelativeArrangement = true
stackView.spacing = UIStackView.spacingUseSystem
self.addSubview(stackView)
self.buttonGroupStackView = stackView
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: topAnchor),
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor)
])
}
// MARK: - Subclass Overrides
override func prepareForReuse() {
super.prepareForReuse()
self.buttonGroupStackView?.subviews.forEach({ $0.removeFromSuperview()} )
}
// MARK: - Private
fileprivate func createButton() -> UIButton {
let button = UIButton(type: .custom)
button.translatesAutoresizingMaskIntoConstraints = false
button.backgroundColor = UIColor.darkGray
button.contentEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5)
button.layer.cornerRadius = 5
button.layer.masksToBounds = true
return button
}
}
extension ButtonCell {
fileprivate func anchorSubViews() {
guard let buttons = buttonGroupContent?.enumerated() else { return }
for (index, b) in buttons {
let btn = self.createButton()
btn.setTitle(b.label, for: .normal)
btn.frame = CGRect(x: 0, y: 0, width: 200, height: 40)
btn.tag = index
self.buttonGroupStackView?.addArrangedSubview(btn)
}
}
}
It is always recommended to use a weak reference to subviews or IBOutlet properties unless you require otherwise.

Related

finding a tableView cells superView

I am trying to create a range slider that has labels representing the sliders handle value. I have the slider enabled but when I try to add the labels to the sliders subview, my app crashes with the error
Thread 1: Fatal error: Unexpectedly found nil while unwrapping an Optional value
The slider is inside of a tableViewCell and I am initializing this cell inside of the tableView VC with the code below,
if indexPath.section == 2 {
let costRangeCell = AgeRangeCell(style: .default, reuseIdentifier: nil)
let contentView = costRangeCell.rangeSlider.superview!
// my declaration of contentView is where my app is crashing.
costRangeCell.rangeSlider.minimumValue = 0
costRangeCell.rangeSlider.maximumValue = 100
costRangeCell.rangeSlider.lowValue = 0
costRangeCell.rangeSlider.highValue = 100
costRangeCell.rangeSlider.minimumDistance = 20
let lowLabel = UILabel()
contentView.addSubview(lowLabel)
lowLabel.textAlignment = .center
lowLabel.frame = CGRect(x:0, y:0, width: 60, height: 20)
let highLabel = UILabel()
contentView.addSubview(highLabel)
highLabel.textAlignment = .center
highLabel.frame = CGRect(x: 0, y: 0, width: 60, height: 20)
costRangeCell.rangeSlider.valuesChangedHandler = { [weak self] in
let lowCenterInSlider = CGPoint(x:costRangeCell.rangeSlider.lowCenter.x, y: costRangeCell.rangeSlider.lowCenter.y - 30)
let highCenterInSlider = CGPoint(x:costRangeCell.rangeSlider.highCenter.x, y: costRangeCell.rangeSlider.highCenter.y - 30)
let lowCenterInView = costRangeCell.rangeSlider.convert(lowCenterInSlider, to: contentView)
let highCenterInView = costRangeCell.rangeSlider.convert(highCenterInSlider, to: contentView)
lowLabel.center = lowCenterInView
highLabel.center = highCenterInView
lowLabel.text = String(format: "%.1f", costRangeCell.rangeSlider.lowValue)
highLabel.text = String(format: "%.1f", costRangeCell.rangeSlider.highValue)
}
costRangeCell.rangeSlider.addTarget(self, action: #selector(handleMinAgeChange), for: .valueChanged)
let minAge = user?.minSeekingCost ?? SettingsViewController.defaultMinSeekingCost
costRangeCell.rangeLabel.text = " $\(minAge)"
return costRangeCell
}
Is there a different way for me to gain access to the cells range slider superView?
ageRange class,
class AgeRangeCell: UITableViewCell {
let rangeSlider: AORangeSlider = {
let slider = AORangeSlider()
slider.minimumValue = 20
slider.maximumValue = 200
return slider
}()
let rangeLabel: UILabel = {
let label = costRangeLabel()
label.text = "$ "
return label
}()
class costRangeLabel: UILabel {
override var intrinsicContentSize: CGSize {
return .init(width: 80, height: 50)
}
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
contentView.isUserInteractionEnabled = true
let overallStackView = UIStackView(arrangedSubviews: [
UIStackView(arrangedSubviews: [rangeLabel, rangeLabel]),
])
overallStackView.axis = .horizontal
overallStackView.spacing = 16
addSubview(overallStackView)
overallStackView.anchor(top: topAnchor, leading: leadingAnchor, bottom: bottomAnchor, trailing: trailingAnchor, padding: .init(top: 16, left: 16, bottom: 16, right: 16))
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
AORangeSlider is a custom Slider.
Took a look at AORangeSlider...
You want to implement your label tracking inside your custom cell... not in your controller class.
Here's a simple implementation, based on the code you supplied in your question:
class AgeRangeCell: UITableViewCell {
let rangeSlider: AORangeSlider = {
let slider = AORangeSlider()
slider.minimumValue = 0
slider.maximumValue = 100
return slider
}()
let lowLabel = UILabel()
let highLabel = 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() -> Void {
lowLabel.textAlignment = .center
lowLabel.frame = CGRect(x:0, y:0, width: 60, height: 20)
highLabel.textAlignment = .center
highLabel.frame = CGRect(x: 0, y: 0, width: 60, height: 20)
[rangeSlider, lowLabel, highLabel].forEach {
contentView.addSubview($0)
}
rangeSlider.translatesAutoresizingMaskIntoConstraints = false
let g = contentView.layoutMarginsGuide
NSLayoutConstraint.activate([
rangeSlider.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
rangeSlider.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
rangeSlider.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
rangeSlider.heightAnchor.constraint(equalToConstant: 40.0),
])
// avoid auto-layout complaints
let c = rangeSlider.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0)
c.priority = UILayoutPriority(rawValue: 999)
c.isActive = true
rangeSlider.valuesChangedHandler = { [weak self] in
guard let `self` = self else {
return
}
let lowCenterInSlider = CGPoint(x:self.rangeSlider.lowCenter.x, y: self.rangeSlider.lowCenter.y - 30)
let highCenterInSlider = CGPoint(x:self.rangeSlider.highCenter.x, y: self.rangeSlider.highCenter.y - 30)
let lowCenterInView = self.rangeSlider.convert(lowCenterInSlider, to: self.contentView)
let highCenterInView = self.rangeSlider.convert(highCenterInSlider, to: self.contentView)
self.lowLabel.center = lowCenterInView
self.highLabel.center = highCenterInView
self.lowLabel.text = String(format: "%.1f", self.rangeSlider.lowValue)
self.highLabel.text = String(format: "%.1f", self.rangeSlider.highValue)
}
}
}
class RangeTableViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
// register your "slider" cell
tableView.register(AgeRangeCell.self, forCellReuseIdentifier: "ageRangeCell")
// register any other cell classes you'll be using
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "plainCell")
}
override func numberOfSections(in tableView: UITableView) -> Int {
return 3
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if section == 2 {
return 1
}
return 2
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if indexPath.section == 2 {
let cell = tableView.dequeueReusableCell(withIdentifier: "ageRangeCell", for: indexPath) as! AgeRangeCell
cell.rangeSlider.minimumValue = 0
cell.rangeSlider.maximumValue = 100
cell.rangeSlider.lowValue = 0
cell.rangeSlider.highValue = 100
cell.rangeSlider.minimumDistance = 20
return cell
}
let cell = tableView.dequeueReusableCell(withIdentifier: "plainCell", for: indexPath)
cell.textLabel?.text = "\(indexPath)"
return cell
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return "Section Header: \(section)"
}
}
That code will produce this:

iOS - Take all the available space in a horizontal UIStackView

I have UITableViewCell that contains a horizontal UIStackView. The UIStackView contains four views in the following order.
UIImageView UILabel UILabel UIImageView
There are 16 points spacing after the arrangedSubViews. I want that the second UILabel takes all the available space. If there is not enough space, it text should wrap.
I have used the following codes. It works almost. The problem is that even though there is enough space between the UILabel's as you see in the screenshot attached, the second UILabel breaks. But I want it to break only if there is not enough space.
class TableViewCell: UITableViewCell {
static let identifier = "TableViewCell"
private let leadingImageView: UIImageView = {
let view = UIImageView(image: UIImage(systemName: "calendar"))
view.setConstraints(heightConstant: 25, widthConstant: 25)
view.tintColor = .text
return view
}()
private let leadingLabel: UILabel = {
let label = UILabel()
label.textColor = .label
label.text = "Start Date"
label.numberOfLines = 0
label.sizeToFit()
return label
}()
let trailingLabel: UILabel = {
let label = UILabel()
label.text = "Friday, 17 July 2020"
label.textColor = .label
label.numberOfLines = 0
label.textAlignment = .right
return label
}()
let trailingImageView: UIImageView = {
let configuration = UIImage.SymbolConfiguration(pointSize: 12, weight: .light)
let image = UIImage(systemName: "arrowtriangle.down", withConfiguration: configuration)
let view = UIImageView(image: image)
return view
}()
private let superStackView: UIStackView = {
let view = UIStackView()
view.distribution = .fillProportionally
view.alignment = .center
view.spacing = 16
return view
}()
private let containerView: UIView = {
let view = UIView()
view.setContentHuggingPriority(.defaultHigh, for: .horizontal)
view.setContentHuggingPriority(.defaultHigh, for: .vertical)
return view
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setUpSubviews()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
}
extension TableViewCell {
private func setUpSubviews(){
containerView.addSubview(trailingImageView)
trailingImageView.alignCenter(centerXAnchor: containerView.centerXAnchor,
centerYAnchor: containerView.centerYAnchor)
superStackView.addArrangedSubview(leadingImageView)
superStackView.addArrangedSubview(leadingLabel)
superStackView.addArrangedSubview(trailingLabel)
superStackView.addArrangedSubview(containerView)
let constant = CGFloat(16)
self.addSubview(superStackView)
self.contentView.addSubview(superStackView)
superStackView.setConstraints(topAnchor: contentView.topAnchor, leadingAnchor: contentView.leadingAnchor,
bottomAnchor: contentView.bottomAnchor, trailingAnchor: contentView.trailingAnchor,
topConstant: constant, leadingConstant: constant, bottomConstant: constant, trailingConstant: constant)
self.contentView.updateConstraints()
}
}
How can I fix this issue so that the cell looks like the cells in the following image?
You're close...
First, setting a stack view's Distribution to Fill Proportionally is the most misunderstood distribution option, and you don't want to use it here.
Second, it helps greatly when designing to use contrasting backgrounds to make it easy to see what your frames are doing.
Here is your code, modified to get to what appears to be your goal. I don't have your "constraint helpers" so I changed it to standard constraint format. I also added a few comments for some clarification:
class TableViewCell: UITableViewCell {
static let identifier = "TableViewCell"
private let leadingImageView: UIImageView = {
let view = UIImageView(image: UIImage(systemName: "calendar"))
view.translatesAutoresizingMaskIntoConstraints = false
//view.setConstraints(heightConstant: 25, widthConstant: 25)
view.widthAnchor.constraint(equalToConstant: 25).isActive = true
view.heightAnchor.constraint(equalToConstant: 25).isActive = true
view.tintColor = .blue // .text
return view
}()
private let leadingLabel: UILabel = {
let label = UILabel()
label.textColor = .label
label.text = "Start Date"
// only single line for "leading label"
label.numberOfLines = 1
// content hugging
label.setContentHuggingPriority(.required, for: .horizontal)
label.backgroundColor = .cyan
return label
}()
let trailingLabel: UILabel = {
let label = UILabel()
label.text = "Friday, 17 July 2020"
label.textColor = .label
label.numberOfLines = 0
label.textAlignment = .right
label.backgroundColor = .green
return label
}()
let trailingImageView: UIImageView = {
let configuration = UIImage.SymbolConfiguration(pointSize: 12, weight: .light)
let image = UIImage(systemName: "arrowtriangle.down", withConfiguration: configuration)
let view = UIImageView(image: image)
return view
}()
private let superStackView: UIStackView = {
let view = UIStackView()
view.translatesAutoresizingMaskIntoConstraints = false
// do NOT use .fillProportionally
view.distribution = .fill
view.alignment = .center
view.spacing = 16
return view
}()
private let containerView: UIView = {
let view = UIView()
// not needed
//view.setContentHuggingPriority(.defaultHigh, for: .horizontal)
//view.setContentHuggingPriority(.defaultHigh, for: .vertical)
return view
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setUpSubviews()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
}
extension TableViewCell {
private func setUpSubviews(){
containerView.addSubview(trailingImageView)
//trailingImageView.alignCenter(centerXAnchor: containerView.centerXAnchor,
// centerYAnchor: containerView.centerYAnchor)
superStackView.addArrangedSubview(leadingImageView)
superStackView.addArrangedSubview(leadingLabel)
superStackView.addArrangedSubview(trailingLabel)
superStackView.addArrangedSubview(containerView)
let constant = CGFloat(16)
self.addSubview(superStackView)
self.contentView.addSubview(superStackView)
//superStackView.setConstraints(topAnchor: contentView.topAnchor, leadingAnchor: contentView.leadingAnchor,
// bottomAnchor: contentView.bottomAnchor, trailingAnchor: contentView.trailingAnchor,
// topConstant: constant, leadingConstant: constant, bottomConstant: constant, trailingConstant: constant)
NSLayoutConstraint.activate([
trailingImageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
trailingImageView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
superStackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: constant),
superStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: constant),
superStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -constant),
superStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -constant),
])
// not needed
//self.contentView.updateConstraints()
}
}
And, an example view controller:
class MahanTableViewController: UITableViewController {
var myData: [String] = [
"Saturday, 18 July 2020",
"Sunday, 19 July 2020",
"Wednesday, 22 July 2020",
"Saturday, 26 September 2020",
"Sunday, 27 September 2020",
"Wednesday, 30 September 2020",
]
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(TableViewCell.self, forCellReuseIdentifier: TableViewCell.identifier)
}
// MARK: - Table view data source
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return myData.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: TableViewCell.identifier, for: indexPath) as! TableViewCell
cell.trailingLabel.text = myData[indexPath.row]
return cell
}
}
Producing this output:
Try to Return 1
let trailingLabel: UILabel = {
let label = UILabel()
label.text = "Friday, 17 July 2020"
label.textColor = .label
label.numberOfLines = 1
label.textAlignment = .right
return label
}()

How to properly ensure a custom UITableViewCell can be reused

I have a UITableViewController that is rendering out a custom UITableViewCell'.
This cells are related to chat messages, as such the config is almost identical, apart from how the elements are constrained.
bot cell is: avatar > message
user cell is message < avatar
I was hoping to combine these in a single custom cell and simply switch on an origin property on the model, allowing me to choose which constraints I am applying.
This worked for 5 or 6 messages, until however I ran a test with 30 messages and some cells started to inherit both sets of anchors or even just random properties that should be assigned to the other cell.
I can see the errors suggest the constraints are invalid and I believe this is due to the cell not being prepared for reuse correctly.
(
"<NSLayoutConstraint:0x600002533930 UIImageView:0x7fb401514d40.leading == UILayoutGuide:0x600003f18e00'UIViewLayoutMarginsGuide'.leading (active)>",
"<NSLayoutConstraint:0x600002526990 UITextView:0x7fb40200a200'I am a Person.'.leading == UILayoutGuide:0x600003f18e00'UIViewLayoutMarginsGuide'.leading + 15 (active)>",
"<NSLayoutConstraint:0x6000025271b0 UITextView:0x7fb40200a200'I am a Person.'.trailing == UIImageView:0x7fb401514d40.leading - 15 (active)>"
)
ChatMessageCell
class ChatMessageCell: UITableViewCell {
fileprivate var content: ChatMessage? {
didSet {
guard let text = content?.text else { return }
messageView.text = text
guard let origin = content?.origin else { return }
setupSubViews(origin)
}
}
fileprivate var messageAvatar: UIImageView = {
let imageView = UIImageView(frame: .zero)
imageView.layer.cornerRadius = 35 / 2
imageView.layer.masksToBounds = true
return imageView
}()
fileprivate var messageView: UITextView = {
let textView = UITextView()
textView.isScrollEnabled = false
textView.isSelectable = false
textView.sizeToFit()
textView.layoutIfNeeded()
textView.contentInset = UIEdgeInsets(top: 5, left: 10, bottom: 5, right: 10)
textView.layer.cornerRadius = 10
textView.layer.maskedCorners = [.layerMaxXMaxYCorner, .layerMaxXMinYCorner, .layerMinXMinYCorner]
textView.translatesAutoresizingMaskIntoConstraints = false
return textView
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = UIColor.clear
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setContent(as content: ChatMessage) {
self.content = content
}
override func prepareForReuse() {
content = nil
}
}
extension ChatMessageCell {
fileprivate func setupSubViews(_ origin: ChatMessageOrigin) {
let margins = contentView.layoutMarginsGuide
[messageAvatar, messageView].forEach { v in contentView.addSubview(v) }
switch origin {
case .system:
messageAvatar.image = #imageLiteral(resourceName: "large-bot-head")
messageAvatar.anchor(
top: margins.topAnchor, leading: margins.leadingAnchor, size: CGSize(width: 35, height: 35)
)
messageView.anchor(
top: margins.topAnchor, leading: messageAvatar.trailingAnchor, bottom: margins.bottomAnchor, trailing: margins.trailingAnchor,
padding: UIEdgeInsets(top: 5, left: 15, bottom: 0, right: 15)
)
case .user:
let userContentBG = UIColor.hexStringToUIColor(hex: "00f5ff")
messageAvatar.image = UIImage.from(color: userContentBG)
messageAvatar.anchor(
top: margins.topAnchor, trailing: margins.trailingAnchor, size: CGSize(width: 35, height: 35)
)
messageView.layer.backgroundColor = userContentBG.cgColor
messageView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMinYCorner, .layerMinXMinYCorner]
messageView.anchor(
top: margins.topAnchor, leading: margins.leadingAnchor, bottom: margins.bottomAnchor, trailing: messageAvatar.leadingAnchor,
padding: UIEdgeInsets(top: 5, left: 15, bottom: 0, right: 15)
)
}
}
}
ChatController
class ChatController: UITableViewController {
lazy var viewModel: ChatViewModel = {
let viewModel = ChatViewModel()
return viewModel
}()
fileprivate let headerView: UIView = {
let view = UIView(frame: .zero)
view.backgroundColor = .white
return view
}()
override func viewDidLoad() {
super.viewDidLoad()
viewModel.reloadData = { [weak self] in
DispatchQueue.main.async {
self?.tableView.reloadData()
}
}
configureViewHeader()
configureTableView()
registerTableCells()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
tableView.contentInset = UIEdgeInsets(top: 85, left: 0, bottom: 0, right: 0)
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.history.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let item = viewModel.history[indexPath.row]
let cell = tableView.dequeueReusableCell(withClass: ChatMessageCell.self)
cell.setContent(as: item)
cell.layoutSubviews()
return cell
}
}
extension ChatController {
fileprivate func configureViewHeader() {
let margins = view.safeAreaLayoutGuide
view.addSubview(headerView)
headerView.anchor(
top: margins.topAnchor, leading: margins.leadingAnchor, trailing: margins.trailingAnchor,
size: CGSize(width: 0, height: 70)
)
}
fileprivate func configureTableView() {
tableView.tableFooterView = UIView()
tableView.allowsSelection = false
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 200
tableView.separatorStyle = .none
tableView.backgroundColor = UIColor.clear
}
fileprivate func registerTableCells() {
tableView.register(cellWithClass: ChatMessageCell.self)
}
}
An example of how the view changes on scroll can be seen here....
My Extensions are applied with
#discardableResult
func anchor(top: NSLayoutYAxisAnchor? = nil, leading: NSLayoutXAxisAnchor? = nil, bottom: NSLayoutYAxisAnchor? = nil, trailing: NSLayoutXAxisAnchor? = nil, padding: UIEdgeInsets = .zero, size: CGSize = .zero) -> AnchoredConstraints {
translatesAutoresizingMaskIntoConstraints = false
var anchoredConstraints = AnchoredConstraints()
if let top = top {
anchoredConstraints.top = topAnchor.constraint(equalTo: top, constant: padding.top)
}
if let leading = leading {
anchoredConstraints.leading = leadingAnchor.constraint(equalTo: leading, constant: padding.left)
}
if let bottom = bottom {
anchoredConstraints.bottom = bottomAnchor.constraint(equalTo: bottom, constant: -padding.bottom)
}
if let trailing = trailing {
anchoredConstraints.trailing = trailingAnchor.constraint(equalTo: trailing, constant: -padding.right)
}
if size.width != 0 {
anchoredConstraints.width = widthAnchor.constraint(equalToConstant: size.width)
}
if size.height != 0 {
anchoredConstraints.height = heightAnchor.constraint(equalToConstant: size.height)
}
[anchoredConstraints.top, anchoredConstraints.leading, anchoredConstraints.bottom, anchoredConstraints.trailing, anchoredConstraints.width, anchoredConstraints.height].forEach { $0?.isActive = true }
return anchoredConstraints
}
In your ChatMessageCell class, move:
[messageAvatar, messageView].forEach { v in contentView.addSubview(v) }
from setupSubViews(...) to init(...). With your current code, setupSubViews is being called every time you set the content. You only want to add the subviews to the cell's contentView when the cell is initialized.
From there, you need to check how you're adding constraints. If your .anchor(...) func / extension is first removing any existing constraints, you should be ok.
Edit:
Here is another option - you may find it easier to work with.
Since you have the same subviews, set up two arrays of constraints. Then activate or deactivate the appropriate set (as well as setting colors, corners, etc):
class ChatMessageCell: UITableViewCell {
fileprivate var content: ChatMessage? {
didSet {
guard let text = content?.text else { return }
messageView.text = text
guard let origin = content?.origin else { return }
setupSubViews(origin)
}
}
fileprivate var messageAvatar: UIImageView = {
let imageView = UIImageView(frame: .zero)
imageView.layer.cornerRadius = 35 / 2
imageView.layer.masksToBounds = true
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
fileprivate var messageView: UITextView = {
let textView = UITextView()
textView.isScrollEnabled = false
textView.isSelectable = false
textView.sizeToFit()
textView.layoutIfNeeded()
textView.contentInset = UIEdgeInsets(top: 5, left: 10, bottom: 5, right: 10)
textView.layer.cornerRadius = 10
textView.translatesAutoresizingMaskIntoConstraints = false
return textView
}()
fileprivate var systemConstraints = [NSLayoutConstraint]()
fileprivate var userConstraints = [NSLayoutConstraint]()
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
func setContent(as content: ChatMessage) {
self.content = content
}
func commonInit() -> Void {
backgroundColor = .clear
let margins = contentView.layoutMarginsGuide
[messageAvatar, messageView].forEach { v in contentView.addSubview(v) }
systemConstraints = [
messageAvatar.leadingAnchor.constraint(equalTo: margins.leadingAnchor, constant: 0.0),
messageView.leadingAnchor.constraint(equalTo: messageAvatar.trailingAnchor, constant: 15.0),
messageView.trailingAnchor.constraint(equalTo: margins.trailingAnchor, constant: -15),
]
userConstraints = [
messageView.leadingAnchor.constraint(equalTo: margins.leadingAnchor, constant: 15.0),
messageAvatar.trailingAnchor.constraint(equalTo: margins.trailingAnchor, constant: 0.0),
messageAvatar.leadingAnchor.constraint(equalTo: messageView.trailingAnchor, constant: 15),
]
NSLayoutConstraint.activate([
// messageAvatar width/height/top is the same for each origin "type"
messageAvatar.topAnchor.constraint(equalTo: margins.topAnchor, constant: 0.0),
messageAvatar.heightAnchor.constraint(equalToConstant: 35),
messageAvatar.widthAnchor.constraint(equalToConstant: 35),
// messageView width/height/top is the same for each origin "type"
messageView.topAnchor.constraint(equalTo: margins.topAnchor, constant: 5.0),
messageView.bottomAnchor.constraint(equalTo: margins.bottomAnchor, constant: 0.0),
])
}
}
extension ChatMessageCell {
fileprivate func setupSubViews(_ origin: ChatMessageOrigin) {
switch origin {
case .system:
NSLayoutConstraint.deactivate(userConstraints)
NSLayoutConstraint.activate(systemConstraints)
messageView.backgroundColor = .white
messageAvatar.backgroundColor = .red
messageView.layer.maskedCorners = [.layerMaxXMaxYCorner, .layerMaxXMinYCorner, .layerMinXMinYCorner]
default:
NSLayoutConstraint.deactivate(systemConstraints)
NSLayoutConstraint.activate(userConstraints)
messageView.backgroundColor = .cyan
messageAvatar.backgroundColor = .blue
messageView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMinYCorner, .layerMinXMinYCorner]
}
}
}
Note: I'm using Swift 4.1, so there are a couple of syntax changes (but they'll be obvious).
When you have two different layouts of cells, having two different classes of cells would be another way to handle your issue.
ChatMessageCell
class ChatMessageCell: UITableViewCell {
fileprivate var content: ChatMessage? {
didSet {
guard let text = content?.text else { return }
messageView.text = text
}
}
//...
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = UIColor.clear
setupSubViews()
}
fileprivate func setupSubViews() {
[messageAvatar, messageView].forEach { v in contentView.addSubview(v) }
}
//...
}
class UserMessageCell: ChatMessageCell {
fileprivate override func setupSubViews() {
super.setupSubViews()
let margins = contentView.layoutMarginsGuide
let userContentBG = UIColor.hexStringToUIColor(hex: "00f5ff")
messageAvatar.image = UIImage.from(color: userContentBG)
messageAvatar.anchor(
top: margins.topAnchor, trailing: margins.trailingAnchor, size: CGSize(width: 35, height: 35)
)
messageView.layer.backgroundColor = userContentBG.cgColor
messageView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMinYCorner, .layerMinXMinYCorner]
messageView.anchor(
top: margins.topAnchor, leading: margins.leadingAnchor, bottom: margins.bottomAnchor, trailing: messageAvatar.leadingAnchor,
padding: UIEdgeInsets(top: 5, left: 15, bottom: 0, right: 15)
)
}
}
class SystemMessageCell: ChatMessageCell {
fileprivate override func setupSubViews() {
super.setupSubViews()
let margins = contentView.layoutMarginsGuide
messageAvatar.image = #imageLiteral(resourceName: "large-bot-head")
messageAvatar.anchor(
top: margins.topAnchor, leading: margins.leadingAnchor, size: CGSize(width: 35, height: 35)
)
messageView.anchor(
top: margins.topAnchor, leading: messageAvatar.trailingAnchor, bottom: margins.bottomAnchor, trailing: margins.trailingAnchor,
padding: UIEdgeInsets(top: 5, left: 15, bottom: 0, right: 15)
)
}
}
ChatController
class ChatController: UITableViewController {
//...
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let item = viewModel.history[indexPath.row]
let cell: ChatMessageCell
switch item.origin {
case .system:
cell = tableView.dequeueReusableCell(withClass: SystemMessageCell.self)
case .user:
cell = tableView.dequeueReusableCell(withClass: UserMessageCell.self)
}
cell.setContent(as: item)
cell.layoutSubviews()
return cell
}
}
extension ChatController {
//...
fileprivate func registerTableCells() {
tableView.register(cellWithClass: SystemMessageCell.self)
tableView.register(cellWithClass: UserMessageCell.self)
}
}

How to create a custom segment control in Swift

I want to create a segment control that works like in the screenshot.
The selected segment should be underlined according to the segment heading text. I have searched for that, but did not find any third party solution.
So how can I develop this type of segment control?
Here you can see that the line at the bottom only stretches across the selected segment.
There is an open source project in GitHub named PageMenu. Please have a look, you can even customize the source file CAPSPageMenu.
https://github.com/PageMenu/PageMenu
To update width of the selection hair line, enable the below property.
menuItemWidthBasedOnTitleTextWidth
Code:
let parameters: [CAPSPageMenuOption] = [
...
.menuItemWidthBasedOnTitleTextWidth(true),
....]
// Initialize scroll menu
pageMenu = CAPSPageMenu(viewControllers: controllerArray, frame: CGRect(x: 0.0, y: 0.0, width: self.view.frame.width, height: self.view.frame.height), pageMenuOptions: parameters)
Please try PageMenuDemoStoryboard demo in the project and update parameters as shown in above code.
import UIKit
extension UIView {
func constraintsEqualToSuperView() {
if let superview = self.superview {
NSLayoutConstraint.activate(
[
self.topAnchor.constraint(
equalTo: superview.topAnchor
),
self.bottomAnchor.constraint(
equalTo: superview.bottomAnchor
),
self.leadingAnchor.constraint(
equalTo: superview.leadingAnchor
),
self.trailingAnchor.constraint(
equalTo: superview.trailingAnchor
)
]
)
}
}
}
protocol ButtonsViewDelegate: class {
func didButtonTap(buttonView: ButtonsView, index: Int)
}
class ButtonsView: UIView {
fileprivate let stackView = UIStackView()
fileprivate var array = [String]()
fileprivate var buttonArray = [UIButton]()
fileprivate let baseTag = 300
weak var delegate: ButtonsViewDelegate?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
fileprivate func setupUI () {
setupStackView()
setupButton()
}
convenience init(buttons: [String]) {
self.init()
array = buttons
setupUI()
//selectButton(atIndex: 0)
}
fileprivate func setupStackView() {
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.distribution = .fillEqually
stackView.constraintsEqualToSuperView()
}
fileprivate func setupButton() {
for (i,string) in array.enumerated() {
let button = UIButton()
button.setTitle(string.uppercased(), for: .normal)
// button.backgroundColor = UIColor.lightBackgroundColor().lightened(by: 0.2)
button.translatesAutoresizingMaskIntoConstraints = false
button.addTarget(
self,
action: #selector(buttonClicked(sender:)),
for: .touchUpInside
)
button.setTitleColor(
.black,
for: .normal
)
button.titleLabel?.font = UIFont.systemFont(
ofSize: 14,
weight: UIFont.Weight.bold
)
// let view = UIView.init(frame: CGRect.init(x: 0, y: button.frame.size.height - 1, width: button.frame.size.width, height: 1))
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = UIColor.clear
view.tag = baseTag + i
button.addSubview(view)
NSLayoutConstraint.activate([
view.leadingAnchor.constraint(
equalTo: button.leadingAnchor
),
view.trailingAnchor.constraint(
equalTo: button.trailingAnchor
),
view.bottomAnchor.constraint(
equalTo: button.bottomAnchor
),
view.heightAnchor.constraint(
equalToConstant: 1
)
])
stackView.addArrangedSubview(button)
buttonArray.append(button)
}
}
func selectButton(atIndex index: Int) {
if index <= buttonArray.count {
buttonClicked(sender: buttonArray[index])
}
}
#objc private func buttonClicked(sender: UIButton) {
for button in buttonArray {
if button == sender {
button.setTitleColor(
UIColor.darkGray,
for: .normal
)
setUpBottomLine(button: button)
}else{
button.setTitleColor(
.black,
for: .normal
)
hideBottomLine(button: button)
}
}
if let index = buttonArray.index(of: sender) {
delegate?.didButtonTap(buttonView: self, index: index)
}
}
private func setUpBottomLine(button: UIButton) {
if let index = buttonArray.index(of: button) {
if let view = button.viewWithTag(baseTag + index) {
view.backgroundColor = UIColor.red
}
}
}
private func hideBottomLine(button: UIButton) {
if let index = buttonArray.index(of: button) {
if let view = button.viewWithTag(baseTag + index) {
view.backgroundColor = .clear
}
}
}
}
//how to use
let durationBtns = ButtonsView(buttons: [
NSLocalizedString(
"day",
comment: ""
),
NSLocalizedString(
"week",
comment: ""
),
NSLocalizedString(
"month",
comment: ""
),
NSLocalizedString(
"year",
comment: ""
)
])
durationButtons = durationBtns
durationButtons.selectButton(atIndex: 0)
durationBtns.delegate = self
//handle buttton tap
extension viewController: ButtonsViewDelegate {
func didButtonTap(buttonView: ButtonsView, index: Int) {
print(index.description)
}
}

UISwitch toggle target not executing

I am completely lost. I have a toggle button(UISwitch) in one of my screens I have added a target to the switch to recognize changes in the switch. However when the switch is toggled nothing happens and I am confused and lost.
import Foundation
import UIKit
class PrivateCell: UITableViewCell {
var stackView: UIStackView?
let switchStatementLabel : UILabel = {
let switchStatementLabel = UILabel()
switchStatementLabel.textAlignment = .justified
switchStatementLabel.text = "Make Profile Private"
return switchStatementLabel
}()
let privateSwitch : UISwitch = {
let privateSwitch = UISwitch(frame: CGRect(x: 0, y: 0, width: 70, height: 70))
privateSwitch.isOn = false
privateSwitch.onTintColor = UIColor.rgb(red: 44, green: 152, blue: 229)
privateSwitch.addTarget(self, action: #selector(switchToggled(_:)), for: UIControlEvents.valueChanged)
return privateSwitch
}()
#objc func switchToggled(_ sender: UISwitch) {
if privateSwitch.isOn {
print("switch turned off")
}else{
print("switch turned on")
}
}
#objc func setupViews(){
backgroundColor = .white
stackView = UIStackView(arrangedSubviews: [ switchStatementLabel, privateSwitch])
stackView?.axis = .horizontal
stackView?.distribution = .equalSpacing
// stackView?.spacing = 10.0
if let firstStackView = stackView{
self.addSubview(firstStackView)
firstStackView.snp.makeConstraints { (make) in
make.edges.equalTo(self).inset(10)
}
}
}
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupViews()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
I have added my code could anyone help me please
Try this
import Foundation
import UIKit
class PrivateCell: UITableViewCell {
var stackView: UIStackView?
let switchStatementLabel : UILabel = {
let switchStatementLabel = UILabel()
switchStatementLabel.textAlignment = .justified
switchStatementLabel.text = "Make Profile Private"
return switchStatementLabel
}()
let privateSwitch : UISwitch = {
let privateSwitch = UISwitch(frame: CGRect(x: 0, y: 0, width: 70, height: 70))
privateSwitch.isOn = false
privateSwitch.onTintColor = UIColor.rgb(red: 44, green: 152, blue: 229)
return privateSwitch
}()
#objc func switchToggled(_ sender: UISwitch) {
if privateSwitch.isOn {
print("switch turned off")
}else{
print("switch turned on")
}
}
#objc func setupViews(){
privateSwitch.addTarget(self, action: #selector(switchToggled(_:)), for: UIControlEvents.valueChanged)
backgroundColor = .white
stackView = UIStackView(arrangedSubviews: [ switchStatementLabel, privateSwitch])
stackView?.axis = .horizontal
stackView?.distribution = .equalSpacing
// stackView?.spacing = 10.0
if let firstStackView = stackView{
self.addSubview(firstStackView)
firstStackView.snp.makeConstraints { (make) in
make.edges.equalTo(self).inset(10)
}
}
}
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupViews()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
#IBOutlet var swtOnlineOfline: UISwitch!
swtOnlineOffline.addTarget(self, action: #selector(self.onlineOfflineSwitchValueChange(_:)), for: .valueChanged)
#IBAction func onlineOfflineSwitchValueChange(_ sender: UISwitch)
{
if sender.isOn
{
swtOnlineOffline.setOn(false, animated: true)
}
else
{
swtOnlineOffline.setOn(true, animated: true)
}
}

Resources