Search code examples
iosswiftindexingtableviewexpand

Expand/Collapse indexed tableView cells with searchController


I have a tableView with different subcategories ("Algrebra","Biology","Chemistry") who are indexed and searchable via the searchController. I want to put these subcategories inside multiple categories ("Urgent","Important","Not Important") and expand/collapse them on click. I also want to have the categories indexed (instead of the subcategories) but keep the subcategories searchable via the searchController.

enter image description here

I don't know how to implement it properly with my code.

Here's my code:

CategoryController

class CategoryController: UIViewController, UITableViewDataSource, UITableViewDelegate, UISearchBarDelegate, UISearchResultsUpdating {

    private var searchController = UISearchController()

let categories = ["Urgent", "Important", "Not Important"] 

let subcategories = [                                                                  
        Add(category: "Algrebra", categoryImg: #imageLiteral(resourceName: "Algebra.png")),
        Add(category: "Biology", categoryImg: #imageLiteral(resourceName: "Biology.png")),
        Add(category: "Chemistry", categoryImg: #imageLiteral(resourceName: "Chemistry.png")),
    ]
    private var sectionTitles = [String]()
    private var filteredSectionTitles = [String]()
    private var sortedCategory = [(key: String, value: [Add])]()
    private var filteredCategory = [(key: String, value: [Add])]()

  private let tableView: UITableView = {
    let table = UITableView()
    table.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        return table }()

  override func viewDidLoad() {
        super.viewDidLoad()
//TABLEVIEW
        tableView.rowHeight = 50
        tableView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(tableView)
        tableView.register(CategoryCell.self, forCellReuseIdentifier: "cell")
        tableView.dataSource = self
        tableView.delegate = self
        tableView.sectionIndexColor = .black
        tableView.sectionIndexBackgroundColor = .lightGray
        tableView.sectionIndexTrackingBackgroundColor = .gray
        tableView.allowsMultipleSelection = false
//SEARCHCONTROLLER
        self.searchController = UISearchController(searchResultsController: nil)
        self.searchController.searchResultsUpdater = self
        self.searchController.obscuresBackgroundDuringPresentation = false
        self.searchController.searchBar.placeholder = "Search for your category"
        self.searchController.hidesNavigationBarDuringPresentation = false
        self.navigationItem.searchController = self.searchController
        self.navigationItem.hidesSearchBarWhenScrolling = false
        self.navigationItem.title = "Tasks"
        navigationController?.navigationBar.prefersLargeTitles = true
        self.searchController.searchBar.searchTextField.textColor = .label
        
        
        let groupedList = Dictionary(grouping: self.subcategories, by: { String($0.category.prefix(1)) })
        self.sortedCategory = groupedList.sorted{$0.key < $1.key}
        
        for tuple in self.sortedCategory {
            self.sectionTitles.append(tuple.key)
        }
    }
//VIEWDIDLAYOUT
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        tableView.frame = view.bounds
    }
/// TABLEVIEW
     func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        if self.searchController.isActive && !self.filteredSectionTitles.isEmpty {
            return self.filteredSectionTitles[section]
        } else {
            return self.sectionTitles[section]
        }
    }
    func sectionIndexTitles(for tableView: UITableView) -> [String]? {
        if self.searchController.isActive && !self.filteredSectionTitles.isEmpty {
            return self.filteredSectionTitles
        } else {
            return self.sectionTitles
        }
    }
    func numberOfSections(in tableView: UITableView) -> Int {
        if self.searchController.isActive && !self.filteredSectionTitles.isEmpty {
            return self.filteredSectionTitles.count
        } else {
            return self.sectionTitles.count
        }
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        if self.searchController.isActive && !self.filteredCategory.isEmpty {
            return self.filteredCategory[section].value.count
        } else {
            return self.sortedCategory[section].value.count
        }
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for:indexPath) as UITableViewCell

        cell.imageView?.contentMode = .scaleAspectFit
        if self.searchController.isActive && !self.filteredCategory.isEmpty {
            cell.textLabel?.text = self.filteredCategory[indexPath.section].value[indexPath.row].category
            cell.imageView?.image = self.filteredCategory[indexPath.section].value[indexPath.row].categoryImg
        } else {
            cell.textLabel?.text = self.sortedCategory[indexPath.section].value[indexPath.row].category
            cell.imageView?.image = self.sortedCategory[indexPath.section].value[indexPath.row].categoryImg
            
        }
        return cell
        
    }
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let currentCell = tableView.cellForRow(at: indexPath)! as UITableViewCell
        Add.details.category = (currentCell.textLabel?.text)!
        let secondVC = DateController()
        navigationController?.pushViewController(secondVC, animated: true)
        print(Add.details.category)

    }
func updateSearchResults(for searchController: UISearchController) {
    
    guard let text = self.searchController.searchBar.text else {
        return
    }
    let filteredCategory = self.sortedCategory.flatMap { $0.value.filter { $0.category.contains(text) } }
    let groupedCategory = Dictionary(grouping: filteredCategory, by: { String($0.category.prefix(1)) } )
    self.filteredCategory = []
    self.filteredCategory = groupedCategory.sorted{ $0.key < $1.key }
    
    self.filteredSectionTitles = []
    for tuple in self.filteredCategory {
        self.filteredSectionTitles.append(tuple.key)
    }
    
    self.tableView.reloadData()
}
}

CategoryCell

class CategoryCell: UITableViewCell {
    var cellImageView = UIImageView()
    var cellLabel = UILabel()
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
            super.init(style: style, reuseIdentifier: "cell")
            
            cellImageView.translatesAutoresizingMaskIntoConstraints = false
            cellImageView.contentMode = .scaleAspectFit
            cellImageView.tintColor = .systemPink
            contentView.addSubview(cellImageView)
            
            cellLabel.translatesAutoresizingMaskIntoConstraints = false
            cellLabel.font = UIFont.systemFont(ofSize: 20)
            contentView.addSubview(cellLabel)
            
            NSLayoutConstraint.activate([
                cellImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
                cellImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
                cellImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
                cellImageView.widthAnchor.constraint(equalToConstant: 44),
                
                cellLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
                cellLabel.leadingAnchor.constraint(equalTo: cellImageView.trailingAnchor, constant: 10),
                
            ])
        }
        
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
    
    override func setSelected(_ selected: Bool, animated: Bool) {
        super.setSelected(selected, animated: animated)

     }
    
        
}

Add(DataStruct)

struct Add {
    static var details: Add = Add()
    var category: String = ""
    
    func getDict() -> [String: Any] {
              let dict = ["category": self.category,
                         
                ] as [String : Any]
               return dict
         }

}

Solution

  • Couple tips that should help...

    First, let's change some naming.

    You're using your "Categories" of "Urgent","Important","Not Important" as Sections ... and your "subcategories" would be more accurately described as "Categories".

    We can also think of the Sections as perhaps Category Status

    So, we'll create an enum like this:

    enum CategoryStatus: Int, CustomStringConvertible {
        case urgent
        case important
        case notimportant
        
        var description : String {
            switch self {
            case .urgent: return "Urgent"
            case .important: return "Important"
            case .notimportant: return "Not Important"
            }
        }
        
        var star : UIImage {
            switch self {
            case .urgent: return UIImage(named: "star") ?? UIImage()
            case .important: return UIImage(named: "halfstar") ?? UIImage()
            case .notimportant: return UIImage(named: "emptystar") ?? UIImage()
            }
        }
    }
    

    And we'll add a "status" property to the Category struct:

    struct MyCategory {
        var name: String = ""
        var categoryImg: UIImage = UIImage()
        var status: CategoryStatus = .important
    }
    

    Now, we can work through the process using "plain language":

    • start by sorting the entire category list by name
    • when we type a search string, we can filter that list by "name contains search"
    • when can then group that list by status

    So if we start with:

    Biology : .important
    Chemistry : .urgent
    Algebra : .urgent
    

    we can sort on name and get

    Algebra : .urgent
    Biology : .important
    Chemistry : .urgent
    

    then group by status

    .urgent
        Algebra
        Chemistry
    .important
        Biology
    

    If we have typed "b" in the search field, we start with our sorted ALL list, and filter it:

    Algebra : .urgent
    Biology : .important
    

    then group by status

    .urgent
        Algebra
    .important
        Biology
    

    Another tip: instead of using a "full list" and a "filtered list", along with a bunch of

    if self.searchController.isActive && !self.filteredSectionTitles.isEmpty {
    

    blocks, use a single sorted, filtered and grouped list.

    That list will then be set to either A) the FULL list (if there is no search text entered) or B) the Filtered list

    Here is a complete example you can try out. I used a bunch of random topics as Categories, and used numbers in circles for each category image, and I used pngs of star, halfstar and emptystar.

    Please note this is Example Code Only!. It is not meant to be, and should not be considered to be, "Production Ready":

    enum CategoryStatus: Int, CustomStringConvertible {
        case urgent
        case important
        case notimportant
        
        var description : String {
            switch self {
            case .urgent: return "Urgent"
            case .important: return "Important"
            case .notimportant: return "Not Important"
            }
        }
        
        var star : UIImage {
            switch self {
            case .urgent: return UIImage(named: "star") ?? UIImage()
            case .important: return UIImage(named: "halfstar") ?? UIImage()
            case .notimportant: return UIImage(named: "emptystar") ?? UIImage()
            }
        }
    }
    
    struct MyCategory {
        var name: String = ""
        var categoryImg: UIImage = UIImage()
        var status: CategoryStatus = .important
    }
    
    class CategoryController: UIViewController, UITableViewDataSource, UITableViewDelegate, UISearchBarDelegate, UISearchResultsUpdating {
        
        private var searchController = UISearchController()
        
        // array of ALL Categories, sorted by name
        private var sortedCategories: [MyCategory] = []
        
        // this will be either ALL items, or the filtered items
        //  grouped by Status
        private var sortedByStatus = [(key: CategoryStatus, value: [MyCategory])]()
        
        private let tableView = UITableView()
        
        private let noMatchesLabel: UILabel = {
            let v = UILabel()
            v.backgroundColor = .yellow
            v.text = "NO Matches"
            v.textAlignment = .center
            return v
        }()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            var items: [MyCategory] = []
            
            // this will be our list of MyCategory objects (they'll be sorted later)
            let itemNames: [String] = [
                "Algebra",
                "Chemistry",
                "Biology",
                "Computer Sciences",
                "Physics",
                "Earth Sciences",
                "Geology",
                "Political Science",
                "Psychology",
                "Nursing",
                "Economics",
                "Agriculture",
                "Communications",
                "Engineering",
                "Foreign Lanuages",
                "English Language",
                "Literature",
                "Libary Sciences",
                "Social Sciences",
                "Visual Arts",
            ]
            
            // create our array of MyCategory
            //  setting every 3rd one to .urgent, .important or .notimportant
            for (str, i) in zip(itemNames, 0...30) {
                let status: CategoryStatus = CategoryStatus.init(rawValue: i % 3) ?? .important
                var img: UIImage = UIImage()
                if let thisImg = UIImage(named: str) {
                    img = thisImg
                } else {
                    if let thisImg = UIImage(systemName: "\(i).circle") {
                        img = thisImg
                    }
                }
                items.append(MyCategory(name: str, categoryImg: img, status: status))
            }
            
            // sort the full list of categories by name
            self.sortedCategories = items.sorted{$0.name < $1.name}
            
            //TABLEVIEW
            tableView.rowHeight = 50
            tableView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(tableView)
            tableView.sectionIndexColor = .black
            tableView.sectionIndexBackgroundColor = .lightGray
            tableView.sectionIndexTrackingBackgroundColor = .gray
            tableView.allowsMultipleSelection = false
            
            tableView.dataSource = self
            tableView.delegate = self
            
            tableView.register(CategoryCell.self, forCellReuseIdentifier: CategoryCell.reuseIdentifier)
            tableView.register(MySectionHeaderView.self, forHeaderFooterViewReuseIdentifier: MySectionHeaderView.reuseIdentifier)
            
            //SEARCHCONTROLLER
            self.searchController = UISearchController(searchResultsController: nil)
            self.searchController.searchResultsUpdater = self
            self.searchController.obscuresBackgroundDuringPresentation = false
            self.searchController.searchBar.placeholder = "Search for your category"
            self.searchController.hidesNavigationBarDuringPresentation = false
            self.navigationItem.searchController = self.searchController
            self.navigationItem.hidesSearchBarWhenScrolling = false
            self.navigationItem.title = "Tasks"
            navigationController?.navigationBar.prefersLargeTitles = true
            self.searchController.searchBar.searchTextField.textColor = .label
            
            // add the no-matches view
            noMatchesLabel.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(noMatchesLabel)
            
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                
                tableView.topAnchor.constraint(equalTo: g.topAnchor),
                tableView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
                tableView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
                tableView.bottomAnchor.constraint(equalTo: g.bottomAnchor),
                
                noMatchesLabel.widthAnchor.constraint(equalTo: g.widthAnchor, multiplier: 0.7),
                noMatchesLabel.heightAnchor.constraint(equalToConstant: 120.0),
                noMatchesLabel.centerXAnchor.constraint(equalTo: g.centerXAnchor),
                noMatchesLabel.topAnchor.constraint(equalTo: tableView.frameLayoutGuide.topAnchor, constant: 40.0),
                
            ])
            
            noMatchesLabel.isHidden = true
            
            // call updateSearchResults to build the initial non-filtered data
            updateSearchResults(for: searchController)
            
        }
        
        /// TABLEVIEW
        func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
            let v = tableView.dequeueReusableHeaderFooterView(withIdentifier: MySectionHeaderView.reuseIdentifier) as! MySectionHeaderView
            v.imageView.image = self.sortedByStatus[section].key.star
            v.label.text = self.sortedByStatus[section].key.description
            return v
        }
        func sectionIndexTitles(for tableView: UITableView) -> [String]? {
            // first char of each section title
            return (sortedByStatus.map { $0.key.description }).compactMap { String($0.prefix(1)) }
        }
        func numberOfSections(in tableView: UITableView) -> Int {
            return self.sortedByStatus.count
        }
        
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            return self.sortedByStatus[section].value.count
        }
        
        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            let cell = tableView.dequeueReusableCell(withIdentifier: CategoryCell.reuseIdentifier, for:indexPath) as! CategoryCell
            
            cell.cellLabel.text = self.sortedByStatus[indexPath.section].value[indexPath.row].name
            cell.cellImageView.image = self.sortedByStatus[indexPath.section].value[indexPath.row].categoryImg
            
            return cell
            
        }
        func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
            
            // get Category object from data
            let thisCategory: MyCategory = self.sortedByStatus[indexPath.section].value[indexPath.row]
            print("selected:", thisCategory.name, "status:", thisCategory.status)
            
        }
        func updateSearchResults(for searchController: UISearchController) {
            
            var filteredList: [MyCategory] = []
            
            if let text = self.searchController.searchBar.text, !text.isEmpty {
                
                // we have text to search for, so filter the list
                filteredList = self.sortedCategories.filter { $0.name.localizedCaseInsensitiveContains(text) }
                
            } else {
                
                // no text to search for, so use the full list
                filteredList = self.sortedCategories
                
            }
            
            // filteredList is now either ALL Categories (no search text entered), or
            //  ALL Categories filtered by search text
            
            // create a dictionary of items grouped by status
            let groupedList = Dictionary(grouping: filteredList, by: { $0.status })
            
            // order the grouped list by status
            self.sortedByStatus = groupedList.sorted{$0.key.rawValue < $1.key.rawValue}
            
            // show noMatchesLabel if we have NO matching Categories
            noMatchesLabel.isHidden = self.sortedByStatus.count != 0
            
            // reload the table
            self.tableView.reloadData()
            
        }
    }
    
    // simple cell with image view and label
    class CategoryCell: UITableViewCell {
        
        static let reuseIdentifier: String = String(describing: self)
        
        var cellImageView = UIImageView()
        var cellLabel = UILabel()
        
        override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
            super.init(style: style, reuseIdentifier: reuseIdentifier)
            
            cellImageView.translatesAutoresizingMaskIntoConstraints = false
            cellImageView.contentMode = .scaleAspectFit
            cellImageView.tintColor = .systemPink
            contentView.addSubview(cellImageView)
            
            cellLabel.translatesAutoresizingMaskIntoConstraints = false
            cellLabel.font = UIFont.systemFont(ofSize: 20)
            contentView.addSubview(cellLabel)
            
            NSLayoutConstraint.activate([
                
                cellImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
                cellImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
                cellImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
                cellImageView.widthAnchor.constraint(equalToConstant: 44),
                
                cellLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
                cellLabel.leadingAnchor.constraint(equalTo: cellImageView.trailingAnchor, constant: 10),
                
            ])
        }
        
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        
        override func setSelected(_ selected: Bool, animated: Bool) {
            super.setSelected(selected, animated: animated)
            
        }
        
    }
    
    // simple reusable section header with image view and label
    class MySectionHeaderView: UITableViewHeaderFooterView {
        
        static let reuseIdentifier: String = String(describing: self)
        
        let imageView = UIImageView()
        let label = UILabel()
        
        override init(reuseIdentifier: String?) {
            super.init(reuseIdentifier: reuseIdentifier)
            
            imageView.contentMode = .scaleAspectFit
            label.font = .systemFont(ofSize: 20.0, weight: .bold)
            
            [imageView, label].forEach { v in
                v.translatesAutoresizingMaskIntoConstraints = false
                contentView.addSubview(v)
            }
            
            let g = contentView.layoutMarginsGuide
            NSLayoutConstraint.activate([
                
                imageView.widthAnchor.constraint(equalToConstant: 24.0),
                imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor),
                imageView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
                imageView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
                
                label.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 12.0),
                label.topAnchor.constraint(equalTo: g.topAnchor),
                label.bottomAnchor.constraint(equalTo: g.bottomAnchor),
                label.trailingAnchor.constraint(equalTo: g.trailingAnchor),
                
            ])
            
        }
        
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
        }
        
    }
    

    Here's how it looks when launched:

    enter image description here

    then, as we type "t" "e" "ra":

    enter image description here

    enter image description here

    enter image description here


    Edit

    The for (str, i) in zip(itemNames, 0...30) { block in the example code was just an easy way to generate some example items.

    To use this in your code, you would likely do something like this:

    let items = [
        MyCategory(name: "Algebra", categoryImg: #imageLiteral(resourceName: "Algebra.png"), status: .urgent),                                                                 
        MyCategory(name: "Biology", categoryImg: #imageLiteral(resourceName: "Biology.png"), status: .important),                                                                 
        MyCategory(name: "Chemistry", categoryImg: #imageLiteral(resourceName: "Chemistry.png"), status: .notimportant),
        // and so on                                                                 
    ]