How to add UserDefaults to save a table view button status?

I have a table view with a "like" button. Users can tap to like the content in each cell. I want to use UserDefaults to persist the button status so that it gets saved if a user closes the app. I'm a new developer so I was hoping for guidance on getting started.

Here is the code for the table view cell: import UIKit

class QuotesTableViewCell: UITableViewCell {

  @IBOutlet weak var likeButton: UIButton!
  @IBOutlet weak var quoteLabel: UILabel!
  override func awakeFromNib() {
    super.awakeFromNib()
    // Initialization code
  }

  override func setSelected(_ selected: Bool, animated: Bool) {
    super.setSelected(selected, animated: animated)

    // Configure the view for the selected state
  }
  @IBAction func likeTapped(_ sender: UIButton) {
     
    if likeButton.tag == 0{
      likeButton.setImage(UIImage(named: "GreenHeart"), for: .normal)
      likeButton.tag = 1
    } else{
       
      likeButton.setImage(UIImage(named: "blankHeart"), for: .normal)
      likeButton.tag = 0

       
    }
  }
   
}

And here is the code for the view controller:

import UIKit

class QuotesMainViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
   

  @IBOutlet weak var quotesTableView: UITableView!
   
  let quoteList = ["Quote1", "Quote2", "Quote3"]
   
  override func viewDidLoad() {
    super.viewDidLoad()
     
    quotesTableView.delegate = self
    quotesTableView.dataSource = self
     
     
  }
   
   
  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return quoteList.count
  }
   
  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = quotesTableView.dequeueReusableCell(withIdentifier: "cell") as! QuotesTableViewCell
     
    cell.quoteLabel.text = quoteList[indexPath.row] as! String
    return cell
  }
   
   
   
}
Accepted Answer

The first principle in a TableView is that all data used to configure cells are stored in the dataSource. NEVER use cell as a dataStorage, it is only for data display. If you use as data storage, when you dequeue, you get wrong values in cell.

  • In your case, your dataSource could be an array, defined in QuotesMainViewController:
struct Quote {
  var label: String
  var like: Bool = false // a priori, false
}
var quoteList: [Quote]?
  • And in viewDidLoad:
quoteList = [Quote(label: "Quote1"), Quote(label: "Quote2"), Quote(label: "Quote3")]
  • You use it in cellForRow:
  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = quotesTableView.dequeueReusableCell(withIdentifier: "cell") as! QuotesTableViewCell
     
    cell.quoteLabel.text = quoteList[indexPath.row].label
    cell.likeButton.tag = indexPath.row    // Use tag to reference the cell, not to set true / false
    cell.likeButton.setImage(UIImage(named: quoteList[indexPath.row].like ? "GreenHeart" : "blankHeart"), for: .normal)

    return cell
  }
  • Put the IBAction in QuotesMainViewController and use the dataSource:
  @IBAction func likeTapped(_ sender: UIButton) {
     
    quoteList[sender.tag].like.toggle()  // update the dataSource ; sender.tag gives the row in the array
    if quoteList[sender.tag].like {
     sender.setImage(UIImage(named: "GreenHeart"), for: .normal) // You can change here or ask for a reloadData()
    } else {
      sender.setImage(UIImage(named: "blankHeart"), for: .normal)
    }
  }
  • Then, when needed, save quoteList in UserDefaults, or just extract the like values:
let quoteLikes :[Bool] = quoteList.map() { $0.like }

But it is better to save the full array

  • save defaults when appropriate (may be at the end of IBAction):
  @IBAction func likeTapped(_ sender: UIButton) {
     
    quoteList[sender.tag].like.toggle()  // update the dataSource ; sender.tag gives the row in the array
    if quoteList[sender.tag].like {
     sender.setImage(UIImage(named: "GreenHeart"), for: .normal) // You can change here or ask for a reloadData()
    } else {
      sender.setImage(UIImage(named: "blankHeart"), for: .normal)
    }
    let defaults = UserDefaults.standard
    defaults.set(quoteList, forKey: "QuoteListKey")    // or defaults.set(quoteLikes, forKey: "LikeKey")
  }
  • read them when needed, in viewDidLoad, by changing
quoteList = [Quote(label: "Quote1"), Quote(label: "Quote2"), Quote(label: "Quote3")]

with

        let defaults = UserDefaults.standard
        quoteList  = UserDefaults.standard.string(forKey: "QuoteListKey") ?? [Quote(label: "Quote1"), Quote(label: "Quote2"), Quote(label: "Quote3")]

It looks like you have not disconnected the IBAction (in tableViewCell) before removing the IBAction in code.

Go to Storyboard, disconnect the like button and reconnect it to the IBAction which is now in QuotesMainViewController.

And do a Clean build folder to clean everything.

Maybe you could show the new version of code for cell and QuotesMainViewController so that we can check.

I cannot tell you how much I appreciate your help. I'm actually getting the error: Cannot assign value of type 'String' to type '[QuotesMainViewController.Quote]' I put the code below.

import UIKit

class QuotesMainViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
   

  @IBOutlet weak var quotesTableView: UITableView!
   
  struct Quote {
   var label: String
   var like: Bool = false // a priori, false
  }
   
  var quoteList: [Quote] = []
   
   
  override func viewDidLoad() {
    super.viewDidLoad()
     
    quotesTableView.delegate = self
    quotesTableView.dataSource = self
     
    let defaults = UserDefaults.standard
    quoteList = UserDefaults.standard.string(forKey: "QuoteListKey") ?? [Quote(label: "Quote1"), Quote(label: "Quote2"), Quote(label: "Quote3")]

     
  }
   
  @IBAction func likeTapped(_ sender: UIButton) {
    
    quoteList[sender.tag].like.toggle() // update the dataSource ; sender.tag gives the row in the array
    if quoteList[sender.tag].like {
     sender.setImage(UIImage(named: "GreenHeart"), for: .normal) // You can change here or ask for a reloadData()
    } else {
     sender.setImage(UIImage(named: "blankHeart"), for: .normal)
    }
    let defaults = UserDefaults.standard
    defaults.set(quoteList, forKey: "QuoteListKey")  // or defaults.set(quoteLikes, forKey: "LikeKey")
     
  }
   
   
  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return quoteList.count
  }
   
  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
   let cell = quotesTableView.dequeueReusableCell(withIdentifier: "cell") as! QuotesTableViewCell
    
    cell.quoteLabel.text = quoteList[indexPath.row].label
   cell.likeButton.tag = indexPath.row  // Use tag to reference the cell, not to set true / false
    cell.likeButton.setImage(UIImage(named: quoteList[indexPath.row].like ? "GreenHeart" : "blankHeart"), for: .normal)

   return cell
  }
   

}
import UIKit

class QuotesTableViewCell: UITableViewCell {

  @IBOutlet weak var likeButton: UIButton!
  @IBOutlet weak var quoteLabel: UILabel!
   
  override func awakeFromNib() {
    super.awakeFromNib()
    // Initialization code
  }

  override func setSelected(_ selected: Bool, animated: Bool) {
    super.setSelected(selected, animated: animated)

    // Configure the view for the selected state
  }
}

Sorry, I went too fast. quoteList cannot match a String, but [Quote]. Hence you need to decode as Object, not String.

Change as this, it should work:

    let defaults = UserDefaults.standard
    quoteList = (UserDefaults.standard.object(forKey: "QuoteListKey") as? [Quote]) ?? [Quote(label: "Quote1"), Quote(label: "Quote2"), Quote(label: "Quote3")]

Hum, I just forgot that we need to encode.

struct must be declared Codable:

     struct Quote: Codable {
      var label: String
      var like: Bool = false // a priori, false
     }

Then you use encode / decode to propertyList in IBAction:

          if let data = try? PropertyListEncoder().encode(quoteList) {
              UserDefaults.standard.set(data, forKey: "QuoteListKey")
          }

And decode (viewDidLoad for instance):

          let defaults = UserDefaults.standard
          if let data = defaults.data(forKey: "QuoteListKey") {
              let array = try! PropertyListDecoder().decode([Quote].self, from: data)
          } else {
               quoteList = [Quote(label: "Quote1"), Quote(label: "Quote2"), Quote(label: "Quote3")]
          }

This time I tested (only way to be sure nothing is missing), it works !

So I got it to work as well until I added the decode to ViewDidLoad. Until that point the table populated and the buttons worked properly, but nothing was saved when the app was closed and reopened (as expected). However, now that I added that part of the code to viewDidLoad, the table does not populate. I only get an empty table. I must have missed something. Would you mind taking one last look?

import UIKit

class QuotesMainViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
   

  @IBOutlet weak var quotesTableView: UITableView!
   
  struct Quote: Codable {
    var label: String
    var like: Bool = false // a priori, false
   }
   
  var quoteList: [Quote] = []
   
   
  override func viewDidLoad() {
    super.viewDidLoad()
     
    quotesTableView.delegate = self
    quotesTableView.dataSource = self
     
    let defaults = UserDefaults.standard
    if let data = defaults.data(forKey: "QuoteListKey") {
      let array = try! PropertyListDecoder().decode([Quote].self, from: data)
    } else {
       quoteList = [Quote(label: "Quote1"), Quote(label: "Quote2"), Quote(label: "Quote3")]
    }

     
     

     
  }
   
  @IBAction func likeTapped(_ sender: UIButton) {
    
    quoteList[sender.tag].like.toggle() // update the dataSource ; sender.tag gives the row in the array
    if quoteList[sender.tag].like {
     sender.setImage(UIImage(named: "GreenHeart"), for: .normal) // You can change here or ask for a reloadData()
      if let data = try? PropertyListEncoder().encode(quoteList) {
        UserDefaults.standard.set(data, forKey: "QuoteListKey")
      }
       
       
    } else {
     sender.setImage(UIImage(named: "blankHeart"), for: .normal)
    }
    
     
  }
   
   
  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return quoteList.count
  }
   
  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
   let cell = quotesTableView.dequeueReusableCell(withIdentifier: "cell") as! QuotesTableViewCell
    
    cell.quoteLabel.text = quoteList[indexPath.row].label
   cell.likeButton.tag = indexPath.row  // Use tag to reference the cell, not to set true / false
    cell.likeButton.setImage(UIImage(named: quoteList[indexPath.row].like ? "GreenHeart" : "blankHeart"), for: .normal)

   return cell
  }
   
   
}

I tested here and it worked…

Look, we load array but never use it!

So change as:

    if let data = defaults.data(forKey: "QuoteListKey") {
      if let array = try? PropertyListDecoder().decode([Quote].self, from: data) {
         quoteList = array
      }
    } else {
       quoteList = [Quote(label: "Quote1"), Quote(label: "Quote2"), Quote(label: "Quote3")]
    }
How to add UserDefaults to save a table view button status?
 
 
Q