• 20 hours
  • Medium

Free online content available in this course.

Videos available in this course

Certificate of achievement available at the end this course

Got it!

Last updated on 4/24/18

Handle keyboard input

Log in or subscribe for free to enjoy all this course has to offer!

Great progress so far! Little by little, our app is taking shape. Next thing to learn is how to process the user’s input and a variety of useful concepts along the way.

Managing input

Activate typing

An intuitive way for the user to request to provide keyboard input is to tap on an input (assuming it looks like an input 😁).

The text field handles this gesture as a trigger to become a first responder.

Back to the text field. As the user taps on it, it becomes a first responder, and iOS ensures the keyboard comes up.

As the user types, their input is placed into the field. That part is taken care of by iOS - the rest is on us. And that is at least to capture what they've typed when they finished.

Process Input

The user will finish typing when they click either the Add button within the cell or the Done button on the keyboard. Let's look at those two circumstances more closely.

The scenario when the user clicks the Add button is more straightforward as we already have a function that handles the button click and we can extract the text from the text property of the text field. Let's create a function placeholder for it:

func processInput() {
        
}

And then call it on button press:

@IBAction func addButtonPressed(_ sender: Any) {
    processInput()
}

We'll complete the method implementation in a bit.

And how do we know when the user has pressed the Done button?

In the other scenario, when we need to capture the Done button press, we'll work with delegate methods; the first thing we'll need to do is declare our cell subclass as a  UITextFieldDelegate :

class NewTaskTableViewCell: UITableViewCell, UITextFieldDelegate {
    // ...
}

All delegate methods are optional, so we don't have to worry about fixing anything.

Now alter the configuration method to assign the delegate (the cell subclass):

func configure() {
    textInput.delegate = self
}

To capture the Done key press, we need to use the text field delegate method  textFieldShouldReturn  where we need to perform the same processing of the user's input as on Add button press and then return  true : 

func textFieldShouldReturn(_ textField: UITextField) -> Bool {
    processInput()
    return true
}

To process input, we'll need to fetch the text property value of the text field. The Done button is only enabled when the user types something (we requested that by checking off the "Auto-enable Return Key" attribute in Interface Builder). But that something can be just spaces! And we don't want to allow that! 😵

So, let's implement a helper function that will clear the air for us before we commit to notifying the controller:

func fetchInput() -> String? {
    if let caption = textInput.text?.trimmingCharacters(in: .whitespaces) {
        return caption.count > 0 ? caption : nil
    }
    return nil
}

The implementation is pretty straight forward:

  • We extract the text value and trim the whitespaces off of it.

  • If the remaining length is still greater than 0, we return a trimmed caption to be shared with the cell delegate.

  • Otherwise, we return nil.

Now let's complete the  processInput()  method:

func processInput() {
    if let caption = fetchInput() {
        delegate?.newTaskCell(self, newTaskCreated: caption)
    }
    textInput.text = ""
    textInput.resignFirstResponder()
}

Let's review those lines:

  • We safely unwrap the value returned by a helper function that takes care of normalizing the input.

  • If there's a non-nil value, we call a delegate method to notify the cell delegate object that the user has requested to create a new task and provide the caption stored in the text field.

  • Assuming the delegate object will take care of the rest, we can now clear the text field making it available for the user to create another task.

  • Finally, we resign the text field from being first responder.

Improve user experience

Next challenge is to guarantee the same behaviour for the Add button as for the Done button. Which is - to make it appear enabled when user types something and disabled when the field is empty.

To address this, let's create a helper method:

    func manageAddButton() {
        var enabled = false
        if let caption = fetchInput() {
            if caption.count > 0 {
                enabled = true
            }
        }
        addButton.isEnabled = enabled
    }

This method needs to be called on a number of occasions:

  • Initial configuration - configure() method

func configure() {
    textInput.delegate = self
    manageAddButton()
}
  • Every time a user types something or removes from the input. For this, we need to create an action for the text field to be triggered on  EditingChanged  event. This is done in the same fashion as creating an action for a button using Interface Builder, just instead of TouchUpInside, select EditingChanged event and name a method  textInputChanged  :

Create text field editing changed action
Create text field editing changed action

Use this method to manage the Add button:

@IBAction func inputTextChanged(_ sender: Any) {
    manageAddButton()
}
  • And, lastly, after we reset the text field value in processInput method:

    func processInput() {
        if let caption = fetchInput() {
            delegate?.newTaskCell(self, newTaskCreated: caption)
        }
        textInput.text = ""
        textInput.resignFirstResponder()
        manageAddButton()
    }

And here's the complete contents of the new task cell subclass:

import Foundation
import UIKit

protocol NewTaskCellDelegate {
    func newTaskCell(_ cell: NewTaskTableViewCell, newTaskCreated caption: String)
}

class NewTaskTableViewCell: UITableViewCell, UITextFieldDelegate {
    
    @IBOutlet weak var textInput: UITextField!
    @IBOutlet weak var addButton: UIButton!
    
    var delegate: NewTaskCellDelegate?
 
    @IBAction func addButtonPressed(_ sender: Any) {
        processInput()
    }
    
    @IBAction func inputTextChanged(_ sender: Any) {
        manageAddButton()
    }
    
    func configure() {
        textInput.delegate = self
        manageAddButton()
    }
    
    override func awakeFromNib() {
        
        super.awakeFromNib()
        
        configure()
    }
    
    func manageAddButton() {
        var enabled = false
        if let caption = fetchInput() {
            if caption.count > 0 {
                enabled = true
            }
        }
        addButton.isEnabled = enabled
    }
    
    func fetchInput() -> String? {
        if let caption = textInput.text?.trimmingCharacters(in: .whitespaces) {
            return caption.count > 0 ? caption : nil
        }
        return nil
    }
    
    func processInput() {
        if let caption = fetchInput() {
            delegate?.newTaskCell(self, newTaskCreated: caption)
        }
        textInput.text = ""
        textInput.resignFirstResponder()
        manageAddButton()
    }
    
    //MARK: text field delegates

    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        processInput()
        return true
    }
}

Here's a programming challenge for you - spot the difference in the behaviour of Done and Add buttons :pirate:.

We are done with the text field functionality here.

Now - onto continuing with the view controller!

Committing a New Task

Switch to ViewController.swift.  

Prepare the Controller

Let's declare that it confirms to our new delegate:

class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate, TaskCellDelegate, NewTaskCellDelegate {
    // ...
}

And fix the usual Xcode complaint by implementing the required delegate method:

func newTaskCell(_ cell: NewTaskTableViewCell, newTaskCreated caption: String) {
        
}

Then, alter the table view datasource delegate method that feeds at the cells (section 0):

case 0:
    let cell = tableView.dequeueReusableCell(withIdentifier: "NewTaskCellID", for: indexPath) as! NewTaskTableViewCell
    cell.delegate = self
    return cell

All we needed to do is to typecast it to the new cell type and assign a delegate.

Now, let's complete adding a new task!

Add a Hot Task!

To add a new task we need to accomplish the following:

  • Create a HotTask object

  • Add a new object to a data source array. We use top priority as a default, therefore will be adding the new object to the priorityTasks array.

  • Add a new cell to the respective section on the table view.

Would you like to try on your own? - Sure you do, go ahead and implement the newTaskCell delegate method!

Nice-nice!
Did I trick you?

Here's my version:

    func newTaskCell(_ cell: NewTaskTableViewCell, newTaskCreated caption: String) {
        
        // create new HOT task:)
        let newTask = HotTask.init(caption: caption)
        
        // insert a new row in the beginning of priority section
        insertTask(newTask, at: IndexPath(row: 0, section: 1))
        
    }

That's right, the only new thing we needed to implement here was an instance of a new task, and we've got the rest already handled by a previously implemented helper method insertTask!

That's all :zorro:!

Managing the Keyboard

Ho-Ho! Run a test! Start typing and then while the keyboard is up, imagine you want to scroll down your task list to remind yourself of the tasks you already added ... You can't! It bounces right back down under the keyboard :waw:!

That's no good! - Let's fix it!

Here's what we want to happen:

  • When the keyboard comes up, we want to limit the area where the table view should be scrolling

  • When the keyboard is dismissed, we want the table view to come back to initial scrolling capacity.

To make this happen we need to be notified when the keyboard shows and hides. We don't control those activities, therefore we'll have to collaborate with iOS via Notification Centre.

Discover Notification Center functionality

Notifications imply two parties:

  • sender

  • receiver, or multiple receivers

Using Notification Center in iOS requires implementing 2 elements:

  • A receiver object must sign up for a particular notification, for example, when the keyboard is about to show or to hide.

  • A receiver needs to implement a handling function to be executed when it receives a notification.

The signing up part is done by adding an observer for a particular event and specifying a method to be executed when the event takes place:

    func registerForKeyboardNotifications() {
        NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: .UIKeyboardWillShow, object: nil)
        NotificationCenter.default.addObserver(self, selector: #selector(self.keyboardWillHide), name: NSNotification.Name.UIKeyboardWillHide, object: nil)
    }
    
    @objc func keyboardWillShow(notification: NSNotification) {
        
    }
    
    @objc func keyboardWillHide(notification: NSNotification){
        
    }

And we need to call this as soon as the app launches - in configure() method:

func configure() {
    tableView.delegate = self
    tableView.dataSource = self
        
    populateInitialTasks()
        
    updateProgress()
        
    registerForKeyboardNotifications()
}

What's left is to actually implement the methods we want to perform before the keyboard will show and hide. To adjust the scrolling space, we'll use the  contentInset  property of the table view. Let's implement a helper function for it:

func adjustLayoutForKeyboard(targetHeight: CGFloat) {
    tableView.contentInset.bottom = targetHeight
}

And call this function for both notifications:

    @objc func keyboardWillShow(notification: NSNotification) {
        let keyboardFrame = notification.userInfo![UIKeyboardFrameEndUserInfoKey] as! CGRect
        adjustLayoutForKeyboard(targetHeight: keyboardFrame.size.height)
    }
    
    @objc func keyboardWillHide(notification: NSNotification){
        adjustLayoutForKeyboard(targetHeight: 0)
    }

And here's a complete code of the view controller so far:

import UIKit

class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate, TaskCellDelegate, NewTaskCellDelegate {

    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var progressLabel: UILabel!
    
    var priorityTasks = [HotTask]()
    var bonusTasks = [HotTask]()
    
    // MARK: Cells delegates
    
    func taskCell(_ cell: TaskTableViewCell, completionChanged completion: Bool) {
        // identify indexPath for a cell
        if let indexPath = tableView.indexPath(for: cell) {
            // fetch datasource for indexPath
            if let task = hotTaskDataSource(indexPath: indexPath) {
                // update the completion state
                task.completed = completion
            }
        }
    }
    
    func newTaskCell(_ cell: NewTaskTableViewCell, newTaskCreated caption: String) {
        
        // create new HOT task:)
        let newTask = HotTask.init(caption: caption)
        
        // add new object to the datasource array
        priorityTasks.insert(newTask, at: 0)

        // put the table view in updating mode
        tableView.beginUpdates()
        
        // insert a new cell to the table
        tableView.insertRows(at: [IndexPath.init(row: 0, section: 1)], with: .automatic)
        
        // finish updating table
        tableView.endUpdates()
        
    }
    
    // MARK: Keyboard management
    
    func registerForKeyboardNotifications() {
        NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: .UIKeyboardWillShow, object: nil)
        NotificationCenter.default.addObserver(self, selector: #selector(self.keyboardWillHide), name: NSNotification.Name.UIKeyboardWillHide, object: nil)
    }
    
    @objc func keyboardWillShow(notification: NSNotification) {
        let keyboardFrame = notification.userInfo![UIKeyboardFrameEndUserInfoKey] as! CGRect
        adjustLayoutForKeyboard(targetHeight: keyboardFrame.size.height)
    }
    
    @objc func keyboardWillHide(notification: NSNotification){
        adjustLayoutForKeyboard(targetHeight: 0)
    }
    
    func adjustLayoutForKeyboard(targetHeight: CGFloat) {
        tableView.contentInset.bottom = targetHeight
    }
    
    // MARK: Table view delegates
    
    func numberOfSections(in tableView: UITableView) -> Int {
        return 3
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        
        switch section {
        case 0:
            return 1
        case 1:
            return priorityTasks.count
        case 2:
            return bonusTasks.count
        default:
            return 0
        }
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        switch indexPath.section {
        case 0:
            let cell = tableView.dequeueReusableCell(withIdentifier: "NewTaskCellID", for: indexPath) as! NewTaskTableViewCell
            cell.delegate = self
            return cell
        case 1:            
            let cell = tableView.dequeueReusableCell(withIdentifier: "TaskCellID", for: indexPath) as! TaskTableViewCell
            let task = hotTaskDataSource(indexPath: indexPath)
            cell.setCaption(task?.caption)
            cell.delegate = self
            return cell
        case 2:
            let cell = tableView.dequeueReusableCell(withIdentifier: "TaskCellID", for: indexPath) as! TaskTableViewCell
            let task = hotTaskDataSource(indexPath: indexPath)
            cell.setCaption(task?.caption)
            cell.delegate = self
            return cell
        default:
            return UITableViewCell.init()
        }
    }
    
    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        switch section {
        case 1:
            return "Top Priority"
        case 2:
            return "Bonus"
        default:
            return nil
        }
    }
    
    func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
        return nil
    }
    
    func hotTaskDataSource(indexPath: IndexPath) -> HotTask? {
        switch indexPath.section {
        case 1:
            return priorityTasks[indexPath.row]
        case 2:
            return bonusTasks[indexPath.row]
        default:
            return nil
        }
    }
    
    func updateProgress() {
        
        // calculate the initial values for task count
        let totalTasks = priorityTasks.count + bonusTasks.count
        let completedTasks = priorityTasks.filter { (task) -> Bool in
            return task.completed == true
        }.count + bonusTasks.filter { (task) -> Bool in
            return task.completed == true
        }.count
        
        // declare a caption variable
        var caption = "What's going on?!"
        
        // handle range possible scenarios
        if totalTasks == 0 { // no tasks
            caption = "It's  lonely here - add some tasks!"
        }
        else if completedTasks == 0 { // nothing completed
            caption = "Get started - \(totalTasks) to go!"
        }
        else if completedTasks == totalTasks { // all completed
            caption = "Well done - \(totalTasks) completed!"
        }
        else { // completedTasks less totalTasks
            caption = "\(completedTasks) down \(totalTasks - completedTasks) to go!"
        }
        
        // assign the progress caption to the label
        progressLabel.text = caption
    }
    
    func populateInitialTasks () {
        priorityTasks.removeAll()
        
        priorityTasks.append(HotTask.init(caption: "Pickup MacBook Pro from Apple store Pickup MacBook Pro from Apple store - type twice for extra length!;)"))
        priorityTasks.append(HotTask.init(caption: "Practice Japanese"))
        priorityTasks.append(HotTask.init(caption: "Buy ingredients for a cake for Alie's bday"))
        
        bonusTasks.removeAll()
        let hotTask = HotTask.init(caption: "Shop for funnky socks")
        hotTask.priority = .bonus
        bonusTasks.append(hotTask)
    }
    
    func configure() {
        tableView.delegate = self
        tableView.dataSource = self
        
        populateInitialTasks()
        
        updateProgress()
        
        registerForKeyboardNotifications()
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        configure()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }

}

Test this now!

Table view scrolling adjustment on keyboard appearance
Table view scrolling adjustment on keyboard appearance

It's working :magicien:!

Let's Recap!

  • In order for a text input to accept keyboard input, it must become a first responder and to finish accepting input it must reigns first responder. There are two corresponding methods that can be used to manage it programmatically: 

    textField.becomeFirstResponder() 
    textField.resignFirstResponder()
  • An object that needs to perform text field input must conform to  UITextFieldDelegate  protocol.

  • To capture an intention to finish providing keyboard input, we can implement a delegate method:  textFieldShouldReturn  .

  • Capturing a user's typing can be done by creating a text field action on  EditingChanged  event.

  • To manage the keyboard, an application must sign up for related events in Notification Center by adding an observer and provide an event handling method for each addition.

Example of certificate of achievement
Example of certificate of achievement