I’m trying to build an app that uses UITableView and UITableView Cell.
I want users to tap an Add button to add an item to the TableView without calling up a new window or an alert pop-up. I also want users to tap a cell to edit its value and then save it.
I’m struggling to find the best way to do this. Based on what Apple documents about UITableViewCell, it doesn’t seem possible.
Is there a better approach?
Here is a very basic example.
In your Storyboard, add a UITableViewController embedded in a UINavigationController. Set the Custom Class of the table view controller to SampleTableViewController. That's all you should need to do to run this.
The table starts out empty. Tap the Add ("+") button on the navigation bar to add a new item to the data array and reload the table.
As you edit a text field, the text will be passed back to the controller via a "callback" closure, where we update the data array with the new string.
There is also a Done button - tapping it will simply print the data array to the debug console so we can see the changes. That is where you'd do something like save the user entered data (or whatever else you're planning to do with it).
SampleTableViewController class
class SampleTableViewController: UITableViewController {
var myData: [String] = []
override func viewDidLoad() {
super.viewDidLoad()
// cells will have text fields, so we want to be able to
// dismiss the keyboard by scrolling the table
tableView.keyboardDismissMode = .onDrag
// register our custom cell
tableView.register(SampleTextFieldCell.self, forCellReuseIdentifier: "cell")
// put system Add "+" button and system "Done" button
// on right side of the navigation bar
let addBtn = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(self.addButtonTapped))
let doneBtn = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(self.doneButtonTapped))
navigationItem.rightBarButtonItems = [doneBtn, addBtn]
}
#objc func addButtonTapped() -> Void {
// add a new element to data array
myData.append("")
// reload the table
tableView.reloadData()
}
#objc func doneButtonTapped() -> Void {
// do something with the added / edited items
// maybe save then to a database?
// for now, just print the data array to the debug console
print(myData)
}
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: "cell", for: indexPath) as! SampleTextFieldCell
cell.theTextField.text = myData[indexPath.row]
// set the "callback" closure so we can save the text as its being edited
cell.callback = { str in
// update data array when text in cell is edited
self.myData[indexPath.row] = str
}
return cell
}
}
SampleTextFieldCell class
class SampleTextFieldCell: UITableViewCell {
let theTextField = UITextField()
// closure used to tell the controller that the text field has been edited
var callback: ((String) ->())?
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 {
theTextField.borderStyle = .roundedRect
theTextField.placeholder = "Enter new item..."
theTextField.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(theTextField)
let g = contentView.layoutMarginsGuide
NSLayoutConstraint.activate([
theTextField.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
theTextField.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
theTextField.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
theTextField.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
])
theTextField.addTarget(self, action: #selector(self.textFieldEdited(_:)), for: .editingChanged)
}
#objc func textFieldEdited(_ textField: UITextField) -> Void {
// send newly edited text back to the controller
callback?(textField.text ?? "")
}
}
Related
I have a UISwitch component in my CreateSomethingViewController. This component is on a xib file.
In my SomethingTableViewCell, I have a label called existsLabel.
When I create my something, I can select as Existent (if I turn my UISwitch component on) or not (if Switch is off).
If my existsLabel was in my CreateSomethingViewController, I would do something like this:
#IBAction func changeSomethingExistence(_ sender: UISwitch) {
let isExistent = sender.isOn
existsLabel.isHidden = false
if isExistent {
existsLabel.isHidden = true
}
}
How can I do this (show my existsLabel on my SomethingTableViewCell) when my UISwitch isOn? Using swift.
I think, you already knew the index or position of your updated objects. So We can reload only visible cells row after updating on particular objects to the index position of your cell.
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as? YourTableViewCell
cell?.yourSwitch.isOn = yourList[indexPath.row].switchIsOne
cell?.yourSwitch.tag = indexPath.row
cell?.yourSwitch.addTarget(self, action: #selector(changeSomethingExistence), for:UIControl.Event.valueChanged)
cell?.existsLabel.isHidden = !yourList[indexPath.row].switchIsOne
return cell!
}
Here is your Switch update actions:
#objc func changeSomethingExistence(mySwitch: UISwitch) {
yourList[mySwitch.tag].switchIsOne = mySwitch.isOn
self.updateCell(indexRow: mySwitch.tag)
}
Call this function from anywhere with your selected index and update the same.
func updateCell(indexRow: Int) {
let updatedIndexPath = IndexPath(row: indexRow, section: 0)
self.tableView.reloadRows(at: [updatedIndexPath], with: .automatic)
}
Here's an example. Instead of hiding and showing a view, I set the background color of the cells. The basic ideas are the same.
Essentially you need an object to store the value that the switch controls. In this case I store that data in the same object that I used as the UITableViewDataSource. When the switch is flipped, you tell that object to change the value. It will broadcast the change to all the cells that are currently listening for the change.
There are lots of ways you could observe the change. You could use the Target Action pattern, you could broadcast the change using the NSNotificationCenter. You could use key/value observers, etc. In this case the object holding the value has an #Published property and the cells subscribe to that property.
One critical thing to do is implement prepareForReuse. When a cell is scrolled off the view, it is put in a reuse queue. Rather than create a new cell the system might hand you one out of the reuse buffer. If it does that, you want to be sure the cell is listening to the right source of information for things that change dynamically.
You should be able to copy/paste this code into an iOS Playground:
//: A UIKit based Playground for presenting user interface
import UIKit
import PlaygroundSupport
import Combine
class CustomCell : UITableViewCell {
var cancelBackgrounds : AnyCancellable?
override func prepareForReuse() {
cancelBackgrounds?.cancel()
cancelBackgrounds = nil
// ALWAYS call super... this can cause hard to identify bugs
super.prepareForReuse()
}
func observeFancyBackground(dataSource: TableData) {
// Set up to observe when the fanch Background value changes
// If this cell was listening to someone else, stop listening to them
// and start listeneing to the new guy.
// This may not be necessary - its a safety check.
cancelBackgrounds?.cancel()
cancelBackgrounds = nil
// Start listening to the new information source
cancelBackgrounds = dataSource.$showFancyBackgrounds.sink(receiveValue: {
isOn in
self.setBackground(isOn)
})
}
private func setBackground(_ showFancy: Bool) {
if showFancy {
self.backgroundConfiguration?.backgroundColor = UIColor.yellow
} else {
self.backgroundConfiguration?.backgroundColor = UIColor.white
}
}
}
class TableData : NSObject, UITableViewDataSource {
let tableData = (1...1000).map { "\(Int($0))" }
#Published var showFancyBackgrounds = false
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return tableData.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "CustomCell", for: indexPath) as! CustomCell
cell.textLabel?.text = tableData[indexPath.row]
cell.observeFancyBackground(dataSource: self)
return cell
}
}
class MyViewController : UIViewController {
let switchView = UISwitch()
let tableView = UITableView(frame: CGRect(x: 0, y: 200, width: 320, height: 100), style: .plain)
let tableData = TableData()
// This is the action called when the switch is toggled.
#objc func switchFlipped(sender: UISwitch) {
tableData.showFancyBackgrounds = sender.isOn
}
// This just sets things up to be pretty.
override func loadView() {
let view = UIView()
switchView.translatesAutoresizingMaskIntoConstraints = false
switchView.addTarget(self, action: #selector(switchFlipped), for: .valueChanged)
tableView.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .white
tableView.register(CustomCell.self, forCellReuseIdentifier: "CustomCell")
tableView.dataSource = tableData
view.addSubview(switchView)
view.addSubview(tableView)
self.view = view
let viewIDs = ["switch" : switchView,
"table" : tableView]
let constraints = [
NSLayoutConstraint.constraints(
withVisualFormat: "V:|-8-[switch]-[table]-|",
options: [],
metrics: nil,
views: viewIDs),
NSLayoutConstraint.constraints(
withVisualFormat: "|-[switch]-|",
options: [],
metrics: nil,
views: viewIDs),
NSLayoutConstraint.constraints(
withVisualFormat: "|-0-[table]-0-|",
options: [],
metrics: nil,
views: viewIDs),
].flatMap { $0 }
view.addConstraints(constraints)
}
}
let myViewController = MyViewController()
PlaygroundPage.current.liveView = myViewController
You can do this by reloading the tableView when the switch is changed.
var isExistent: Bool
#IBAction func changeSomethingExistence(_ sender: UISwitch) {
isExistent = sender.isOn
//reload the table
tableView.reloadData()
}
In your UITableViewDataSource you can check which cell.label need to be hidden or not and accordingly hide/show the label of those cells
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
//decide which cells needs to be hide/show based on the indexPath and switchValue
//then you can call cell.existsLabel.isHidden = isExistent
}
I am using a tableview where the first cell contains two buttons. When the first button is activated, the second should be disabled (you should not be able to press it). It all works fine until I start scrolling down the tableview. If I scroll down as far as possible while still being able to see the buttons in first cell, and I activate the first button, I am still able to press the other one. Other things also stops working, but I guess that it is cause by the same thing. Do you have any idea on what happens? See the gif below to see what is going on
I have uploaded the source code on this link, if you need it
https://github.com/Rawchris/scroll-down
I hope you can help :)
Table view cells are reused - which means a couple things:
in general (particularly for your case) you should only add UI elements when you initialize the cell. Otherwise, new ones get added over and over and over.
you need to maintain "row" information, usually in your data source. In this example, you want at least an array of Bool values indicating whether the button in the row should be enabled or not when the cell is reused.
Change your View Controller class to this:
class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
#IBOutlet weak var tableView: UITableView!
// this would be replaced with your real data
// test with 20 rows
// start with all rows having button enabled
var buttonStatus: [Bool] = Array(repeating: true, count: 20)
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
//Configure the button
tableView.delegate = self
tableView.dataSource = self
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return CGFloat(200)
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return buttonStatus.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "TableViewCell") as! TableViewCell
cell.selectionStyle = UITableViewCell.SelectionStyle.none
cell.setButtonEnabled(buttonStatus[indexPath.row], with: "Row \(indexPath.row)")
cell.callback = { b in
// update data source with enabled state of button
self.buttonStatus[indexPath.row] = b
}
return cell
}
}
and change your cell class to this:
class TableViewCell: UITableViewCell {
var callback: ((Bool) -> ())?
var button = DropDownBtn()
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
button = DropDownBtn.init(frame: CGRect(x: 0, y: 0, width: 0, height: 0))
button.setTitle("Button1", for: .normal)
button.translatesAutoresizingMaskIntoConstraints = false
//Add Button to the View Controller
self.addSubview(button)
//button Constraints
button.leftAnchor.constraint(equalTo: self.centerXAnchor, constant: 30).isActive = true
button.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
button.widthAnchor.constraint(equalToConstant: 100).isActive = true
button.heightAnchor.constraint(equalToConstant: 40).isActive = true
//Set the drop down menu's options
button.dropView.dropDownOptions = ["Option1", "Option2", "Option3", "Option4"]
self.clipsToBounds = false
self.contentView.clipsToBounds=false
}
func setButtonEnabled(_ b: Bool, with title: String) {
button.isUserInteractionEnabled = b
// update the UI - green enabled, red disabled
button.backgroundColor = b ? UIColor(red: 0.0, green: 0.6, blue: 0.0, alpha: 1.0) : .red
// update the title so we can see what row we're on
button.setTitle(title, for: [])
}
#IBAction func deactivate(_ sender: Any) {
// toggle the enabled property of "button"
button.isUserInteractionEnabled = !button.isUserInteractionEnabled
// tell the controller the status changed
callback?(button.isUserInteractionEnabled)
// update the UI - green enabled, red disabled
button.backgroundColor = button.isUserInteractionEnabled ? UIColor(red: 0.0, green: 0.6, blue: 0.0, alpha: 1.0) : .red
}
}
This will demonstrate using an array to track the Bool enabled state for the dropDown button in each row. It also changes the button's background color to Green when enabled, Red when disabled. And, it sets the Title of the dropDown button to make it easy to see which rows you're looking at when you scroll up and down.
So I am pretty new to iOS development. I try to create everything programmatically so my Storyboard is empty. I'm currently trying to get a TableView with custom cells. The TableView is running and looking fine when I use the standard UITableViewCell. I created a very simple class called "GameCell". Basically, I want to create a cell here with multiple labels and maybe some extra UIObjects in the future (imageView etc.). For some reason, the custom cells do not show up.
Game cell class:
class GameCell: UITableViewCell {
var mainTextLabel = UILabel()
var sideTextLabel = UILabel()
func setLabel() {
self.mainTextLabel.text = "FirstLabel"
self.sideTextLabel.text = "SecondLabel"
}
}
Here the additional necessary code to get the number of rows and return the cells to the TableView which I have in my ViewController. self.lastGamesCount is just an Int here and definitely not zero when I print it.
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.lastGamesCount
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: cellID) as! GameCell
In my viewDidLoad() I register the cells like this:
tableView.register(GameCell.self, forCellReuseIdentifier: cellID)
When I run everything the Build is successful I can see the navigation bar of my App and all but the TableView is empty. I go back to the normal UITableViewCell and the cells are showing up again. What am I missing here? Any help is appreciated.
Thanks!
The problem is you need to set constraints for these labels
var mainTextLabel = UILabel()
var sideTextLabel = UILabel()
after you add them to the cell
class GameCell: UITableViewCell {
let mainTextLabel = UILabel()
let sideTextLabel = UILabel()
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setLabel()
}
func setLabel() {
self.mainTextLabel.translatesAutoresizingMaskIntoConstraints = false
self.sideTextLabel.translatesAutoresizingMaskIntoConstraints = false
self.contentView.addSubview(mainTextLabel)
self.contentView.addSubview(sideTextLabel)
NSLayoutConstraint.activate([
mainTextLabel.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor),
mainTextLabel.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor),
mainTextLabel.topAnchor.constraint(equalTo: self.contentView.topAnchor,constant:20),
sideTextLabel.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor),
sideTextLabel.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor),
sideTextLabel.topAnchor.constraint(equalTo: self.mainTextLabel.bottomAnchor,constant:20),
sideTextLabel.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor,constant:-20)
])
self.mainTextLabel.text = "FirstLabel"
self.sideTextLabel.text = "SecondLabel"
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
I have a UITableView that I'm using to show an array of custom objects. Each object has several properties including a Boolean property that indicates if this item is new or not.
My UITableViewCell content view is defined in the storyboard and has an initial layout similar to this:
In my UITableViewController, when I dequeue my cells, I call a method on my UITableViewCell that configures the data to be displayed in the cell before I return it. One of the properties that I check is the .isNew property that I mentioned previously. If this value is true, then I am creating a UIButton and inserting it as a subview in the cell's content view so I end up with something like this:
Just for context, this button will show a "new" image to indicate that this item is new. I am also hooking up a method that will fire when the button is tapped. That method is also defined in my UITableViewCell and looks like this:
#objc func newIndicatorButtonTapped(sender: UIButton!) {
// call delegate method and pass this cell as the argument
delegate?.newIndicatorButtonTapped(cell: self)
}
I have also created a protocol that defines a delegate method. My UITableViewController conforms to this and I see that code fire when I tap on the button in my cell(s). Here's is the delegate method (defined in an extension on my UITableViewController):
func newIndicatorButtonTapped(cell: UITableViewCell) {
if let indexPath = self.tableView.indexPath(for: cell) {
print(indexPath.row)
}
}
I see the row from the indexPath print out correctly in Xcode when I tap on the cell. When my user taps on this button, I need to remove it (the button) and update the constraint for my UILabel so that is aligned again with the leading edge of the content view as shown in the first mockup above. Unfortunately, I seem to be running into an issue with cell recycling because the UIButton is disappearing and re-appearing in different cells as I scroll through them. Do I need to reset the cell's layout/appearance before it gets recycled or am I misunderstanding something about how cell recycling works? Any tips would be much appreciated!
What you may be thinking is that you get a "fresh" cell, but when a cell gets re-cycled that means it gets re-used.
You can see this very easily by changing the text color of a basic cell.
For example:
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "MyID", for: indexPath) as! MyCustomCell
if indexPath.row == 3 {
cell.theLabel.textColor = .red
}
return cell
}
As you would expect, when the table first loads the text color will change for the 4th row (row indexing is zero-based).
However, suppose you have 100 rows? As you scroll, the cells will be re-used ... and each time that original-4th-cell gets re-used, it will still have red text.
So, as you guessed, yes... you need to "reset" your cell to its original layout / content / colors / etc each time you want to use it:
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "MyID", for: indexPath) as! MyCustomCell
if indexPath.row == 3 {
cell.theLabel.textColor = .red
} else {
cell.theLabel.textColor = .black
}
return cell
}
You may want to consider to have the button hidden and then change the layout when it is clicked.
Firing the action from the cell to the tableView with a protocol and then reseting the layout at cell reuse is a good way to do it
Doing it in a cell fully programatic would be like this:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! Cell
cell.isNew = indexPath.row == 0 ? true : false
cell.layoutIfNeeded()
return cell
}
And the cell class needs to be similar to: (you can do what you need by changing the Autolayout constraint, manipulating the frame directly or using a UIStackView)
class Cell: UITableViewCell {
var isNew: Bool = false {
didSet {
if isNew {
button.isHidden = true
leftConstraint.constant = 20
} else {
button.isHidden = false
leftConstraint.constant = 100
}
self.setNeedsLayout()
}
}
var button: UIButton!
var label: UILabel!
var leftConstraint: NSLayoutConstraint!
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
button = UIButton(type: .system)
button.setTitle("Click", for: .normal)
self.contentView.addSubview(button)
button.translatesAutoresizingMaskIntoConstraints = false
button.widthAnchor.constraint(equalToConstant: 50).isActive = true
button.heightAnchor.constraint(equalToConstant: 10).isActive = true
button.leftAnchor.constraint(equalTo: self.leftAnchor, constant: 20).isActive = true
button.topAnchor.constraint(equalTo: self.topAnchor, constant: 20).isActive = true
label = UILabel()
label.text = "Label"
self.contentView.addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
label.widthAnchor.constraint(equalToConstant: 200).isActive = true
label.heightAnchor.constraint(equalToConstant: 20).isActive = true
label.topAnchor.constraint(equalTo: self.topAnchor, constant: 10).isActive = true
leftConstraint = label.leftAnchor.constraint(equalTo: self.leftAnchor, constant: 100)
leftConstraint.isActive = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
Still very much a Swift noob, I have been looking around for a proper way/best practice to manage row deletions in my UITableView (which uses custom UserCells) based on tapping a UIButton inside the UserCell using delegation which seems to be the cleanest way to do it.
I followed this example: UITableViewCell Buttons with action
What I have
UserCell class
protocol UserCellDelegate {
func didPressButton(_ tag: Int)
}
class UserCell: UITableViewCell {
var delegate: UserCellDelegate?
let addButton: UIButton = {
let button = UIButton(type: .system)
button.setTitle("Add +", for: .normal)
button.addTarget(self, action: #selector(buttonPressed), for: .touchUpInside)
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: .subtitle, reuseIdentifier: reuseIdentifier)
addSubview(addButton)
addButton.rightAnchor.constraint(equalTo: self.rightAnchor, constant: -6).isActive = true
addButton.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
addButton.heightAnchor.constraint(equalToConstant: self.frame.height / 2).isActive = true
addButton.widthAnchor.constraint(equalToConstant: self.frame.width / 6).isActive = true
}
func buttonPressed(_ sender: UIButton) {
delegate?.didPressButton(sender.tag)
}
}
TableViewController class:
class AddFriendsScreenController: UITableViewController, UserCellDelegate {
let cellId = "cellId"
var users = [User]()
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return users.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: cellId, for: indexPath) as! UserCell
cell.delegate = self
cell.tag = indexPath.row
return cell
}
func didPressButton(_ tag: Int) {
let indexPath = IndexPath(row: tag, section: 0)
users.remove(at: tag)
tableView.deleteRows(at: [indexPath], with: .fade)
}
}
where the Users in users are appended with a call to the database in the view controller.
My issues
The button in each row of the Table View is clickable but does not do anything
The button seems to be clickable only when doing a "long press", i.e. finger stays on it for a ~0.5s time
Will this method guarantee that the indexPath is updated and will not fall out of scope ? I.e. if a row is deleted at index 0, will deleting the "new" row at index 0 work correctly or will this delete the row at index 1 ?
What I want
Being able to click the button in each row of the table, which would remove it from the tableview.
I must be getting something rather basic wrong and would really appreciate if a Swift knight could enlighten me.
Many thanks in advance.
There are at least 3 issues in your code:
In UserCell you should call:
button.addTarget(self, action: #selector(buttonPressed), for: .touchUpInside)
once your cell has been instantiated (say, from your implementation of init(style:reuseIdentifier:)) so that self refers to an actual instance of UserCell.
In AddFriendsScreenController's tableView(_:cellForRowAt:) you are setting the tag of the cell itself (cell.tag = indexPath.row) but in your UserCell's buttonPressed(_:) you are using the tag of the button. You should modify that function to be:
func buttonPressed(_ sender: UIButton) {
//delegate?.didPressButton(sender.tag)
delegate?.didPressButton(self.tag)
}
As you guessed and as per Prema Janoti's answer you ought to reload you table view once you deleted a row as your cells' tags will be out of sync with their referring indexPaths. Ideally you should avoid relying on index paths to identify cells but that's another subject.
EDIT:
A simple solution to avoid tags being out of sync with index paths is to associate each cell with the User object they are supposed to represent:
First add a user property to your UserCell class:
class UserCell: UITableViewCell {
var user = User() // default with a dummy user
/* (...) */
}
Set this property to the correct User object from within tableView(_:cellForRowAt:):
//cell.tag = indexPath.row
cell.user = self.users[indexPath.row]
Modify the signature of your UserCellDelegate protocol method to pass the user property stored against the cell instead of its tag:
protocol UserCellDelegate {
//func didPressButton(_ tag: Int)
func didPressButtonFor(_ user: User)
}
Amend UserCell's buttonPressed(_:) action accordingly:
func buttonPressed(_ sender: UIButton) {
//delegate?.didPressButton(sender.tag)
//delegate?.didPressButton(self.tag)
delegate?.didPressButtonFor(self.user)
}
Finally, in your AddFriendsScreenController, identify the right row to delete based on the User position in the data source:
//func didPressButton(_ tag: Int) { /* (...) */ } // Scrap this.
func didPressButtonFor(_ user: User) {
if let index = users.index(where: { $0 === user }) {
let indexPath = IndexPath(row: index, section: 0)
users.remove(at: index)
tableView.deleteRows(at: [indexPath], with: .fade)
}
}
Note the if let index = ... construct (optional binding) and the triple === (identity operator).
This downside of this approach is that it will create tight coupling between your User and UserCell classes. Best practice would dictate using a more complex MVVM pattern for example, but that really is another subject...
There is a lot of bad/old code on the web, even on SO. What you posted has "bad practice" written all over it. So first a few pointers:
Avoid an UITableViewController at all cost. Have a normal view controller with a table view on it
Delegates should always be weak unless you are 100% sure what you are doing
Be more specific when naming protocols and protocol methods
Keep everything private if possible, if not then use fileprivate. Only use the rest if you are 100% sure it is a value you want to expose.
Avoid using tags at all cost
The following is an example of responsible table view with a single cell type which has a button that removes the current cell when pressed. The whole code can be pasted into your initial ViewController file when creating a new project. In storyboard a table view is added constraint left, right, top, bottom and an outlet to the view controller. Also a cell is added in the table view with a button in it that has an outlet to the cell MyTableViewCell and its identifier is set to "MyTableViewCell".
The rest should be explained in the comments.
class ViewController: UIViewController {
#IBOutlet private weak var tableView: UITableView? // By default use private and optional. Always. For all outlets. Only expose it if you really need it outside
fileprivate var myItems: [String]? // Use any objects you need.
override func viewDidLoad() {
super.viewDidLoad()
// Attach table viw to self
tableView?.delegate = self
tableView?.dataSource = self
// First refresh and reload the data
refreshFromData() // This is to ensure no defaults are visible in the beginning
reloadData()
}
private func reloadData() {
myItems = nil
// Simulate a data fetch
let queue = DispatchQueue(label: "test") // Just for the async example
queue.async {
let items: [String] = (1...100).flatMap { "Item: \($0)" } // Just generate some string
Thread.sleep(forTimeInterval: 3.0) // Wait 3 seconds
DispatchQueue.main.async { // Go back to main thread
self.myItems = items // Assign data source to self
self.refreshFromData() // Now refresh the table view
}
}
}
private func refreshFromData() {
tableView?.reloadData()
tableView?.isHidden = myItems == nil
// Add other stuff that need updating here if needed
}
/// Will remove an item from the data source and update the array
///
/// - Parameter item: The item to remove
fileprivate func removeItem(item: String) {
if let index = myItems?.index(of: item) { // Get the index of the object
tableView?.beginUpdates() // Begin updates so the table view saves the current state
myItems = myItems?.filter { $0 != item } // Update our data source first
tableView?.deleteRows(at: [IndexPath(row: index, section: 0)], with: .fade) // Do the table view cell modifications
tableView?.endUpdates() // Commit the modifications
}
}
}
// MARK: - UITableViewDelegate, UITableViewDataSource
extension ViewController: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return myItems?.count ?? 0
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if let cell = tableView.dequeueReusableCell(withIdentifier: "MyTableViewCell", for: indexPath) as? MyTableViewCell {
cell.item = myItems?[indexPath.row]
cell.delegate = self
return cell
} else {
return UITableViewCell()
}
}
}
// MARK: - MyTableViewCellDelegate
extension ViewController: MyTableViewCellDelegate {
func myTableViewCell(pressedMainButton sender: MyTableViewCell) {
guard let item = sender.item else {
return
}
// Delete the item if main button is pressed
removeItem(item: item)
}
}
protocol MyTableViewCellDelegate: class { // We need ": class" so the delegate can be marked as weak
/// Called on main button pressed
///
/// - Parameter sender: The sender cell
func myTableViewCell(pressedMainButton sender: MyTableViewCell)
}
class MyTableViewCell: UITableViewCell {
#IBOutlet private weak var button: UIButton?
weak var delegate: MyTableViewCellDelegate? // Must be weak or we can have a retain cycle and create a memory leak
var item: String? {
didSet {
button?.setTitle(item, for: .normal)
}
}
#IBAction private func buttonPressed(_ sender: Any) {
delegate?.myTableViewCell(pressedMainButton: self)
}
}
In your case the String should be replaced by the User. Next to that you will have a few changes such as the didSet in the cell (button?.setTitle(item.name, for: .normal) for instance) and the filter method should use === or compare some id or something.
try this -
update didPressButton method like below -
func didPressButton(_ tag: Int) {
let indexPath = IndexPath(row: tag, section: 0)
users.remove(at: tag)
tableView.reloadData()
}