Search code examples
iosswiftuitableviewswift3

Restrict cell selection per section in UITableView swift


I want to restrict certain sections of my tableview to only allow 1 cell to be selected, as of right now all my cells can be selected regardless of the section it's in

There is a little twist however : My sections are an [array] and change dynamically depending on different variables.

My sections are themselves each, a Variable, so I can pinpoint to them programmatically like this :

var section1 = [NSDictionary(objects: [NSLocalizedString("Alcohol use less than 24 hours", comment:""), 2],

EDIT2: It's been pointed out that I could create a var containing the restriction

var restrictedSections: [[NSDictionary]: Bool] {return  [section1: true,section2: true,section3: true, section4: true, section4COPI: true, section5: true, section5COPI: true, section6: false, section7: false, section8: false] }

This cannot be referenced as an IndexPath, so no luck there... but maybe on the right path?

Some code from the tableView (simplified for StackOverflow readers) :

// checkmarks when tapped

    func tableView(_ tableView: UITableView, didSelectRowAtIndexPath indexPath: IndexPath) {
        if let cell = tableView.cellForRow(at: indexPath) {
            if self.selectedIndexPaths.contains(indexPath) {

                cell.accessoryType = .none
                cell.backgroundColor = UIColor.clear
                self.selectedIndexPaths.remove(indexPath)

                if CrewMembersNumber == "1" {
                    if((indexPath).section == 0) {
                        self.section1score -= section1[(indexPath).row].object(forKey: "value") as! Int
                    } else if((indexPath).section == 1) {
                        self.section3score -= section3[(indexPath).row].object(forKey: "value") as! Int
                    }

                } else if CrewMembersNumber == "2" {
                    if((indexPath).section == 0) {
                        self.section1score -= section1[(indexPath).row].object(forKey: "value") as! Int
                    } else if((indexPath).section == 1) {
                        self.section2score -= section2[(indexPath).row].object(forKey: "value") as! Int
                    }
                } else if CrewMembersNumber == "3" {
                    if((indexPath).section == 0) {
                        self.section1score -= section1[(indexPath).row].object(forKey: "value") as! Int
                    } else if((indexPath).section == 1) {
                        self.section5score -= section5[(indexPath).row].object(forKey: "value") as! Int
                    }

                } else {
                    // if crewmemebernumber doest return 1-2 or 3
                    if((indexPath).section == 0) {
                        self.section1score -= section1[(indexPath).row].object(forKey: "value") as! Int
                    } else if((indexPath).section == 1) {
                        self.section4score -= section4[(indexPath).row].object(forKey: "value") as! Int
                    }

                }

            } else {
                cell.accessoryType = .checkmark
                cell.backgroundColor = UIColor (red:236/255.0, green: 236/255, blue: 236/255, alpha: 1.0)
                self.selectedIndexPaths.add(indexPath)

                if CrewMembersNumber == "1" {
                    if((indexPath).section == 0) {
                        self.section1score += section1[(indexPath).row].object(forKey: "value") as! Int
                    } else if((indexPath).section == 1) {
                        self.section3score += section3[(indexPath).row].object(forKey: "value") as! Int
                    }

                } else if CrewMembersNumber == "2" {
                    if((indexPath).section == 0) {
                        self.section1score += section1[(indexPath).row].object(forKey: "value") as! Int
                    } else if((indexPath).section == 1) {
                        self.section2score += section2[(indexPath).row].object(forKey: "value") as! Int
                    }

                } else if CrewMembersNumber == "3" {
                    if((indexPath).section == 0) {
                        self.section1score += section1[(indexPath).row].object(forKey: "value") as! Int
                    } else if((indexPath).section == 1) {
                        self.section5score += section5[(indexPath).row].object(forKey: "value") as! Int
                    }
                } else {
                    // if crewmemebernumber doest return 1-2 or 3
                    if((indexPath).section == 0) {
                        self.section1score += section1[(indexPath).row].object(forKey: "value") as! Int
                    } else if((indexPath).section == 1) {
                        self.section4score += section4[(indexPath).row].object(forKey: "value") as! Int
                    }
                }
            }
            self.updateToolbarAndLabel(self.totalScore)
            self.tableView.reloadData()
        }
    }


    func tableView(_ tableView: UITableView,cellForRowAt indexPath: IndexPath) -> UITableViewCell {

        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)

        cell.textLabel!.text = self.textForIndexPath(indexPath);
        cell.textLabel!.font = UIFont(name:"Avenir", size:19)
        cell.textLabel!.numberOfLines = 0
        cell.selectionStyle = UITableViewCellSelectionStyle.none;

        if(self.selectedIndexPaths.contains(indexPath)) {

            cell.accessoryType = .checkmark;
            cell.backgroundColor = UIColor (red:236/255.0, green: 236/255, blue: 236/255, alpha: 1.0)
        } else {
            cell.accessoryType = .none;
            cell.backgroundColor = UIColor.clear
        }
        return cell
 }

As you can see when I tap a cell it adds a value and the cell changes it's backgroundColor And adds a checkmark,

What I need to do is if there is a cell that is selected, in certain sections that only 1 can be selected, it needs to check if any cell within that section is selected and deselect it in favor of the new one the user tapped on. Right now I do not understand how to do this at all

thanks for any help


Solution

  • Fundimentally the best solution (IMHO) for tableviews is to create a view model for your table, manipulate the data as required, then reflect that data in the table. Then, you do everything possible to have the table react to data changes as opposed to trying to use the table view itself to reflect data or state.

    EDIT: instead of using reloadData, the code now uses performBatchUpdates for a more elegant presentation.

    I created a project that does what you want and you can find it here.

    The view data is contained here:

    let pilots = "Pilots"
    let crew = "Crew"
    let passengers = "Passengers"
    
    var sections: [String] = []
    var multipleSelectionsAllowed: Set<String> = []
    
    var members: [String: [String]] = [:]
    var selectedMembers: Set<String> = []
    

    the first three string constants allow us to index into the data, and initialized:

    sections = [pilots, crew, passengers] // initial ordering of sections
    multipleSelectionsAllowed = [passengers]
    

    The data is created programmatically, see the attached project or the full code attached below.

    You said the sections may change, so sections is a variable and we'll change it later on.

    selectedMembers contains a hash of the type (i.e. Pilot, Crew, or Passenger and their name, so it should be unique. This array will reflect the current selections, as data and not indexPaths.

    But, we need indexPaths to reflect the isSelected UI changes: fine, we'll use two functions for this:

    typealias KeyToValues = (section: String, name: String)
    
    func sectionNameToHash(section: String, name: String) -> String {
        let hash = section + "|" + name
        return hash
    }
    
    func hashToSectionName(hash: String) -> KeyToValues {
        let array = hash.components(separatedBy: "|")
        assert(array.count == 2)
        return (array[0], array[1])
    }
    

    Also, something I've found very useful in the past is to put the code that changes the look of a cell in a single place, and call it when a cell is created or changed. You won't get out of sync over time as the UI changes too.

    func updateCell(atIndexPath indexPath: IndexPath) {
        let cells = tableView.visibleCells
        for cell in cells {
            guard let path = tableView.indexPath(for: cell) else { continue }
            if path == indexPath {
                updateCell(cell, atIndexPath: indexPath)
            }
        }
    }
    
    func updateCell(_ cell: UITableViewCell, atIndexPath indexPath: IndexPath) {
        let section = sections[indexPath.section]
        guard let names = members[section] else { fatalError() }
        let name = names[indexPath.row]
    
        let hash = sectionNameToHash(section: section, name: name)
        let shouldBeSelected = selectedMembers.contains(hash)
    
        if shouldBeSelected {
            cell.accessoryType = .checkmark
            print("SELECTED", hash)
        } else {
            cell.accessoryType = .none
            print("DESELECTED", hash)
        }
    }
    

    You need both because in some cases you only have an indexPath, not the cell.

    Note that you use the above methods when creating cells:

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
    
        let section = sections[indexPath.section]
        guard let names = members[section] else { fatalError() }
        let name = names[indexPath.row]
    
        cell.textLabel?.text = name
    
        updateCell(cell, atIndexPath: indexPath)
        return cell
    }
    

    When the tableView detects a selection, you will first look at the existing selected data, and first remove that selection from your data, then update any delected cell's UI:

     override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
        let section = sections[indexPath.section]
        guard let names = members[section] else { fatalError() }
    
        let canMultipleSelect = multipleSelectionsAllowed.contains(section)
    
        if !canMultipleSelect, let paths = tableView.indexPathsForSelectedRows {
            for path in paths {
                if path.section == indexPath.section {
                    let name = names[path.row]
                    let hash = sectionNameToHash(section: section, name: name)
                    selectedMembers.remove(hash)
                    updateCell(atIndexPath: path)
                    tableView.deselectRow(at: path, animated: true)
                }
            }
        }
        return indexPath
    }
    

    Then, handle the selection method:

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let section = sections[indexPath.section]
        guard let names = members[section] else { fatalError() }
        let name = names[indexPath.row]
        let hash = sectionNameToHash(section: section, name: name)
    
        selectedMembers.insert(hash)
        print("SELECTED THE CELL AT", hash)
        updateCell(atIndexPath: indexPath)
    }
    

    Voila - everything works as you want. But, even better, you can re-arrange the sections as you said you do and get everything properly selected. The example code re-arranges the sections 5 seconds after you select the first row/column

    if indexPath.section == 0 && indexPath.row == 0 {
        DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
            self.sections = [self.crew, self.pilots, self.passengers] // changed!
            tableView.reloadData()
            // all selections from the tableView are now gone
            // NOTE: none of the other data changes!
            for hash in self.selectedMembers {
                let value = self.hashToSectionName(hash: hash)
                guard
                    let sectionNumber = self.sections.firstIndex(of: value.section),
                    let names = self.members[value.section],
                    let row = names.firstIndex(of: value.name)
                else { fatalError() }
    
                let indexPath = IndexPath(row: row, section: sectionNumber)
                self.tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
            }
        }
    }
    

    The reload() erases all selections, so the above code uses the known selected members to inform the tableView of list of selections, even if the cells for each are not visible.

    The complete class

    import UIKit
    
    private final class MyCell: UITableViewCell {
        override var reuseIdentifier: String? { "cell" }
    }
    
    final class ViewController: UITableViewController {
    
        let pilots = "Pilots"
        let crew = "Crew"
        let passengers = "Passengers"
    
        var sections: [String] = []
        var multipleSelectionsAllowed: Set<String> = []
    
        var members: [String: [String]] = [:]
        var selectedMembers: Set<String> = []
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            tableView.register(MyCell.self, forCellReuseIdentifier: "cell")
            tableView.allowsMultipleSelection = true
    
            sections = [pilots, crew, passengers] // initial ordering of sections
            multipleSelectionsAllowed = [passengers]
    
            constructData()
        }
    
        private func constructData() {
            var array: [String] = []
            (1..<6).forEach { array.append("Pilot \($0)")}
            members[pilots] = array
            array.removeAll()
    
            (1..<20).forEach { array.append("Crew \($0)")}
            members[crew] = array
            array.removeAll()
    
            (1..<250).forEach { array.append("Passenger \($0)")}
            members[passengers] = array
        }
    
        // MARK: - Helpers -
    
        typealias KeyToValues = (section: String, name: String)
    
        func sectionNameToHash(section: String, name: String) -> String {
            let hash = section + "|" + name
            return hash
        }
    
    
        func hashToSectionName(hash: String) -> KeyToValues {
            let array = hash.components(separatedBy: "|")
            assert(array.count == 2)
            return (array[0], array[1])
        }
    
    }
    
    extension ViewController /*: UITableViewDataSource */ {
    
        override func numberOfSections(in: UITableView) -> Int {
            return sections.count
        }
    
        override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            let type = sections[section]
            let count = members[type]?.count ?? 0 // could use guard here too and crash if nil
            return count
        }
    
        override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
    
            let section = sections[indexPath.section]
            guard let names = members[section] else { fatalError() }
            let name = names[indexPath.row]
    
            cell.textLabel?.text = name
    
            updateCell(cell, atIndexPath: indexPath)
            return cell
        }
    
        func updateCell(atIndexPath indexPath: IndexPath) {
            let cells = tableView.visibleCells
            for cell in cells {
                guard let path = tableView.indexPath(for: cell) else { continue }
                if path == indexPath {
                    updateCell(cell, atIndexPath: indexPath)
                }
            }
        }
    
        func updateCell(_ cell: UITableViewCell, atIndexPath indexPath: IndexPath) {
            let section = sections[indexPath.section]
            guard let names = members[section] else { fatalError() }
            let name = names[indexPath.row]
    
            let hash = sectionNameToHash(section: section, name: name)
            let shouldBeSelected = selectedMembers.contains(hash)
    
            if shouldBeSelected {
                cell.accessoryType = .checkmark
                print("SELECTED", hash)
            } else {
                cell.accessoryType = .none
                print("DESELECTED", hash)
            }
        }
    
    }
    
    extension ViewController /* : UITableViewDelegate */ {
    
        override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
            let section = sections[indexPath.section]
            guard let names = members[section] else { fatalError() }
    
            let canMultipleSelect = multipleSelectionsAllowed.contains(section)
    
            if !canMultipleSelect, let paths = tableView.indexPathsForSelectedRows {
                for path in paths {
                    if path.section == indexPath.section {
                        let name = names[path.row]
                        let hash = sectionNameToHash(section: section, name: name)
                        selectedMembers.remove(hash)
                        updateCell(atIndexPath: path)
                        tableView.deselectRow(at: path, animated: true)
                    }
                }
            }
            return indexPath
        }
    
        override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
            let section = sections[indexPath.section]
            guard let names = members[section] else { fatalError() }
            let name = names[indexPath.row]
            let hash = sectionNameToHash(section: section, name: name)
    
            selectedMembers.insert(hash)
            print("SELECTED THE CELL AT", hash)
            updateCell(atIndexPath: indexPath)
    
        if indexPath.section == 0 && indexPath.row == 0 {
            DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
                self.sections = [self.crew, self.pilots, self.passengers]
                tableView.reloadData()
                // all selections from the tableView are gone
    
                for hash in self.selectedMembers {
                    let value = self.hashToSectionName(hash: hash)
                    guard
                        let sectionNumber = self.sections.firstIndex(of: value.section),
                        let names = self.members[value.section],
                        let row = names.firstIndex(of: value.name)
                    else { fatalError() }
    
                    let indexPath = IndexPath(row: row, section: sectionNumber)
                    self.tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
                }
            }
        }
        }
    
        override func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
            print("DESELECTED THE CELL AT", hash)
    
            let section = sections[indexPath.section]
            guard let names = members[section] else { fatalError() }
            let name = names[indexPath.row]
            let hash = sectionNameToHash(section: section, name: name)
            selectedMembers.remove(hash)
    
            updateCell(atIndexPath: indexPath)
        }
    
    }