Solution - All Challenges Combined

Knowing that working with lists and tables is a HUGE part of native development, I tried to combine all three challenges in this chapter to create a single working solution for all three. I have it working, and my code is below. There are 2 sections, if a section doesn’t have any items, it displays “No Items” in that section, and you can favorite items in either section. Additionally, when moving an item, you cannot move it across sections (which makes sense. if the item is above $50, you shouldn’t be able to move it to the "below $50 section). The “Favorite” toggles and indicator use Star icons/images, and the “Favorites” toggle button at the top is hidden when there are no favorites in either section. The favorites toggle button will display “Favorites” when showing all items, and when showing only favorites it displays “Show All”

I’d love to get feedback from those with more experience and see what I got wrong or could’ve done better…
ItemStore

class ItemStore {
    /**
     * A flag to indicate if we should only return items marked as favorites,
     * or if we should show all items.
     */
    var showFavoritesOnly = false

    /**
     * An array with 2 elements, each of which is itself an array of Items.
     * This is the underlying data source that contains ALL items.
     *
     * This property is ONLY used for adding/removing/sorting items.
     * To fetch an item for display, use the filteredItems property below.
     */
    private var allItems = [[Item](), [Item]()]
    
    /**
     * A Closure that can be used to filter a list of items to include
     * only items that are flagged as favorites.
     */
    private let itemFilter: (Item) -> Bool = {
        (item: Item) -> Bool in
        return item.isFavorite
    }
    

    /**
     * A filtered version of the above "allitems" array.
     *
     *  When getting items or lists of items to return
     *  for display, this filtered List is used, and NOT the allItems list above.
     *
     * When "showFavoritesOnly" is true, this property will
     * use the above closure to filter each list and only include
     * items flagged as "favorites.
     * Otherwise, this property just returns the entire "allItems" array, since there
     * is no filtering needed.
     */
    private var filteredItems: Array<Array<Item>> {
        if showFavoritesOnly {
            return [allItems[0].filter(itemFilter), allItems[1].filter(itemFilter)]
        } else {
            return allItems
        }
    }
    
    /**
     * The number of lists (sections) to display.
     */
    var listCount: Int {
        return filteredItems.count
    }
    
    /**
     * A computed property that returns true
     * when the underlying data source has at least one "favorite" item.
     * If there are no favorites, returns false.
     */
    var hasFavorites: Bool {
        for i in 0...allItems.count-1 {
            if (allItems[i].contains { $0.isFavorite }) {
                return true
            }
        }
        return false
    }
    

    /**
     * Gets the number of items in one of the 2 lists.
     *
     * Note that we use filteredItems here, and not allItems.
     *  The lists being displayed to the user come from the filteredList,
     *  so we want to use it when calculating the list sizes.
     */
    func getListSize(listIndex index: Int) -> Int {
        return filteredItems[index].count
    }
        
    /**
     * Creates an item and adds it to the appropriate list in the underlying data source.
     *
     * Returns the index (section) in the data source that the item is in,
     * and the index (row) of the item in it's section.
     */
    @discardableResult
    func createItem() -> (listIndex: Int, itemIndex: Int) {
        let newItem = Item(random: true)
        
        var listIndex: Int
        var itemIndex: Int
        
        if newItem.valueInDollars < 50 {
            allItems[0].append(newItem)
            listIndex = 0
            itemIndex = filteredItems[0].count - 1
        } else {
            allItems[1].append(newItem)
            listIndex = 1
            itemIndex = filteredItems[1].count - 1
        }
        
        return (listIndex, itemIndex)
    }

    /**
     * Gets a specific item from the data source from a specific listIndex (section) and itemIndex (row).
     *
     *  Note that this method uses the filteredList to get items.
     */
    func getItem(fromList listIndex: Int, index itemIndex: Int) -> Item {
        return filteredItems[listIndex][itemIndex]
    }
        
    /**
     * Remove an item from the data source.
     *
     * Removes the item from the listIndex (section), if it is found there.
     */
    func removeItem(_ item: Item, fromList listIndex: Int) {
        if let index = allItems[listIndex].firstIndex(of: item) {
            allItems[listIndex].remove(at: index)
        }
    }
    
    /**
     * Move an item from one index to another within it's list (section).
     *
     *  Moving items is tricky when the "favorites" filter is enabled.
     *  the indexes passed into this method are on the filtered list,
     *  but we need to move the item in the underlying (allItems) data source.
     *  Because of that, we have to get the items out of the filtered list, and find
     *  their "destination" index in the underlying (allItems) data source and then move based
     *  on that "destination" index.
     */
    func moveItem(from fromIndex: IndexPath, to toIndex: IndexPath) {
        if fromIndex == toIndex {
            return
        }
        
        // Get the item being moved.
        let item = filteredItems[fromIndex.section][fromIndex.row]

        //Get the item that is currently at the index we want to move to
        let destinationItem = filteredItems[toIndex.section][toIndex.row]
        
        //Figure out the actual index of the adjacentItem in the main data source (allItems)
        let destinationIndex = allItems[toIndex.section].firstIndex(of: destinationItem)!
        
        //Now we can remove the item from it's current location in the allItems list
        removeItem(item, fromList: fromIndex.section)

        // And re-insert it at the new location in the allItems list
        allItems[toIndex.section].insert(item, at: destinationIndex)
    }
    
    
    /**
     * Toggle the "isFavorite" property on a given item.
     */
    func toggleFavoriteItem(_ index: Int, inList listIndex: Int) {
        filteredItems[listIndex][index].isFavorite.toggle()
    }
}

ItemsViewController

class ItemsViewController: UITableViewController {
    var itemStore: ItemStore!
    
    /** The favorites button at the top of the screen  */
    @IBOutlet
    var favoritesButton: UIButton!


     /** When the view is being displayed, we need to show/hide the favorites
      * button. See updateFavoritesButton() for more info.
      */
    override func viewWillAppear(_ animated: Bool) {
        updateFavoritesButton()
        super.viewWillAppear(animated)
    }

    /** Show the favorites button if there are some favorites in the
     * temStore. If not, hide the button, since it won't do anything.
     */
    private func updateFavoritesButton() {
        favoritesButton.isHidden = !itemStore.hasFavorites
        if (itemStore.showFavoritesOnly) {
            favoritesButton.setTitle("Show All", for: .normal)
        } else {
            favoritesButton.setTitle("Favorites", for: .normal)
        }    }
    
    /**
     * Sets the number of sections to display in the table view.
     */
    override func numberOfSections(in tableView: UITableView) -> Int {
        return itemStore.listCount
    }

    /**
     * Setup the section titles.
     */
    override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        switch section {
        case 0:
            return "Cheap Items"
        case 1:
            return "Expensive Items"
        default:
            return ""
        }
    }
    
    /**
     * Tells the TableView how many rows there are in a given section.
     *
     *  Even if the data store has no data for a given section, we still want to return 1 as the row count, so that we can display a "No Items" row.
     */
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return  max(itemStore.getListSize(listIndex: section), 1)
    }
    
    /**
     * Creates and returns a Cell to display in the TableView for a given section/row.
     *
     *  If the specified section has no data, then we'll return the "EmptyListCell", which displays "No Items!"
     *  Otherwise, we'll create a regular cell with the item data.
     */
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
        // See if the section has any items or not.
        if (itemStore.getListSize(listIndex: indexPath.section) == 0) {
            // No data, create an EmptyListCell and return it/
            let cell = tableView.dequeueReusableCell(withIdentifier: "EmptyListCell", for: indexPath)
            return cell
        } else {
            // There is data for this section, get the item and use it to fill in the cell.
            let item = itemStore.getItem(fromList: indexPath.section, index: indexPath.row)

            let cell = tableView.dequeueReusableCell(withIdentifier: "UITableViewCell", for: indexPath)
            
            
            
            cell.textLabel?.text = item.name
            cell.detailTextLabel?.text = "$\(item.valueInDollars)"

            // If the item is a favorite, show a Star icon next to it.
            // Otherwise, don't show an image.
            if item.isFavorite {
                cell.imageView?.image = UIImage(named: "star")?.withTintColor(UIColor.systemBlue)
            } else {
                cell.imageView?.image = nil
            }

            return cell
        }
        
    }
    
    /**
     * Handle row deletions.
     */
    override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
        // We only care about Deletes
        if editingStyle == .delete {

            let item = itemStore.getItem(fromList: indexPath.section, index: indexPath.row)
            itemStore.removeItem(item, fromList: indexPath.section)
            

            if (itemStore.getListSize(listIndex: indexPath.section) == 0) {
                // After removing the item, the section is now empty.
                // So, we don't need to actually delete the row from the tableview.
                // Instead, we just need to reload the row, and the code in the method above above
                // will switch it to an Empty Cell ("No Items").
                tableView.reloadRows(at: [indexPath], with: .automatic)
            } else {
                // Since the section still has items, we need to remove the row that just got deleted.
                tableView.deleteRows(at: [indexPath], with: .automatic)
            }
        }
    }

    /**
     * Callled when the TableView is about to move a row from one position to the other.
     *
     *  We don't want to allow the user to move a row from one section to another.
     *  If an item is more than $50, then you can't move it to the "less than $50 section", and vice versa
     */
    override func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath {

        // Check the source and destination sections, and make sure they are the same.
        if (sourceIndexPath.section != proposedDestinationIndexPath.section) {
            // User is trying to move an item from one section to another,
            // Don't allow it. Just return the source indexPath, meaning "put this item back where it started".
            return sourceIndexPath
        } else {
            // Source and Destination sections are the same, so return the destination indexPath,
            // meaning move this row to the spot the user wants to move it to.
            return proposedDestinationIndexPath
        }
    }

    /**
     * Sets up a handler for when the user swipes an item left to right.
     *
     *  We use this to create a "Favorite" toggle button that can be used to toggle the favorite status on the item.
     */
    override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {

        // Find the item being edited.
        let item = itemStore.getItem(fromList: indexPath.section, index: indexPath.row)

        // Setup a handler to call when the user clicks on the button after swiping the item.
        let handler: (UIContextualAction, UIView, @escaping (Bool)-> Void) -> Void = {
            (action: UIContextualAction, view: UIView, completionHandler: @escaping (Bool)-> Void) -> Void in

            // Toggle the item's "isFavorite" value.
            self.itemStore.toggleFavoriteItem(indexPath.row, inList: indexPath.section)
            // Reload the item, so that it will be updated to show/hide the favorite star.
            tableView.reloadRows(at: [indexPath], with: .none)
            // Since a favorite was added/removed, we need to update the favorites button
            // at the top of the screen, to make sure it's properly shown or hidden based
            // on whether the data set has any favorites.
            self.updateFavoritesButton()

            completionHandler(true)
        }
        
        // Finally, setup the Action on the row.
        let favoriteAction = UIContextualAction(style: .normal, title: "", handler: handler)
        favoriteAction.backgroundColor = UIColor.systemBlue
        if (item.isFavorite) {
            favoriteAction.image = UIImage(named: "starUnfilled")?.withTintColor(UIColor.white)
        } else {
            favoriteAction.image = UIImage(named: "star")?.withTintColor(UIColor.white)
        }
        
        // And return the action.
        return UISwipeActionsConfiguration(actions: [favoriteAction])
    }

    /**
     * Called when a row is moved from one location to another.
     *
     *  We need to update the underlying data source.
     */
    override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
        itemStore.moveItem(from: sourceIndexPath, to: destinationIndexPath)
    }
    
    /**
     * Determines whether a row can be edited.
     *
     *  If a section has no items, then we don't want to allow editing,
     *  because you can't delete/move the "No Items!" cell.
     */
    override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
        return itemStore.getListSize(listIndex: indexPath.section) > 0
    }

    /**
     * A Handler for the "Add" button.
     * Adds a new (random) item to the list.
     */
    @IBAction
    func addNewItem(_ sender: UIButton) {
        let newItem = itemStore.createItem()

        let indexPath = IndexPath(row: newItem.itemIndex, section: newItem.listIndex)
        
        // Like deletes above...
        // If the new item added is the first item in this section,
        // we just need to reload the existing ("No Items") row,
        // which will cause it to update with the item data.
        // Or, if this is not the first item in the section,
        // then we actually need to add a row for the item.
        if (newItem.itemIndex == 0) {
            tableView.reloadRows(at: [indexPath], with: .automatic)
        } else {
            tableView.insertRows(at: [indexPath], with: .top)
        }
        
    }
    
    /**
     * A Handler for the "Edit" button.
     *
     *  Updates the button text, and sets editing mode on the TableView.
     */
    @IBAction
    func toggleEditingMode(_ sender: UIButton) {
        if isEditing {
            sender.setTitle("Edit", for: .normal)
        } else {
            sender.setTitle("Done", for: .normal)
        }
        
        setEditing(!isEditing, animated: true)
    }
    

    /**
     * A handler for the "Favorites" button.
     *
     * Updates the data source favorites toggle,
     * and reloads the entire table, since the data
     * to display is changing drastically/entirely.
     */
    @IBAction
    func toggleFavorites(_ sender: UIButton) {
        itemStore.showFavoritesOnly.toggle()
        tableView.reloadData()
        updateFavoritesButton()
    }
}

This looks OK, but storing the items in two different arrays depending on their value can become an issue. What if you want three sections? or four? What if you allowed the user to edit items & change their price?

It’s possible to store all the items in a single container & still have them split into sections on the display.

1 Like

Thanks for the feedback Jon.
There are probably much better ways to setup the underlying data store for sure. I didn’t put too much effort into that piece; I was primarily concerned with working with and learning the TableView API’s. I guess you could say I had a separation of concerns (LOL, see what I did there? :wink:).

To your point, you could build a much more robust “backend” that provides a flexible and configurable set of sections with titles and lists of items, all backed by a single array (or, in the real world, database table). But I’ll leave that for when I get to the Core Data chapter :slight_smile:. Maybe I’ll come back to this at that point, and set this example up to store the items in a database and build out the “backend” more.