UITextView With Placeholder Text
January 29, 2015

UITextField has pretty support for placeholder text. UITextView doesn’t. So if you need a multi-line editable text component, you don’t get a pretty placeholder. Here’s how to roll your own.

This tutorial has been updated to Swift 2.0.

First, requirements. Let’s set up a UITextField and play with it to figure out exactly what behaviour we need to mimic:

import UIKit

class ViewController: UIViewController {

  let PLACEHOLDER_TEXT = "enter your name"
  
  // MARK: view lifecycle
  
  override func viewDidLoad() {
    super.viewDidLoad()
    
    // for comparison, let's add a UITextField with a placeholder
    let nameTextfield: UITextField? = UITextField(frame: 
       CGRect(x: 20, y: 20, width: self.view.frame.size.width - 40, height: 40))
    nameTextfield?.placeholder = PLACEHOLDER_TEXT
    self.view.addSubview(nameTextfield!)
  }
}

Run that and play with it. Make a list of the requirements. Don’t worry, I’ll wait…

...
cue Jeopardy music
...

Here’s what I got for what we need to implement:

  1. Light gray placeholder text displayed initially
  2. When the user start editing the text, the cursor shows up at the start of the field but the placeholder is still there until they type a character
  3. Once they type a character in the field (i.e., when it has contents) the placeholder text disappears and the text color gets darker
  4. If they delete all of the text in the field, the placeholder re-appears and the cursor is at the start of the field (if they’re still editing it)
  5. If they tap the delete key while the placeholder is shown, nothing happens (i.e., the placeholder text stays visible)

So we’ve got requirements, now we need a text view to apply them to. Let’s stick it below our text field so we can compare the two to test our implementation later:

import UIKit

class ViewController: UIViewController {

  var nameTextView: UITextView?
  let PLACEHOLDER_TEXT = "enter your name"
  
  // MARK: view lifecycle
  
  override func viewDidLoad() {
    super.viewDidLoad()
    
    // for comparison, let's add a UITextField with a placeholder
    let nameTextfield: UITextField? = UITextField(frame: 
      CGRect(x: 20, y: 20, width: self.view.frame.size.width - 40, height: 40))
    nameTextfield?.placeholder = PLACEHOLDER_TEXT
    self.view.addSubview(nameTextfield!)
    
    // create the textView
    nameTextView = UITextView(frame: CGRect(x: 20, y: 80, 
      width: self.view.frame.size.width - 40, height: 40))
    self.view.addSubview(nameTextView!)

    applyPlaceholderStyle(nameTextView!, placeholderText: PLACEHOLDER_TEXT)
  }
}

Nothing too shocking there: make a UITextView (nameTextView) and position it. But it doesn’t cover any of our requirements.

First, let’s make it show the placeholder text. Remember our first requirement: Light gray placeholder text displayed initially

Let’s use a helper function to apply the styling we need. We’ll add a call to it at the bottom of viewDidLoad():

override func viewDidLoad() {
    super.viewDidLoad()
    
    // for comparison, let's add a UITextField with a placeholder
    let nameTextfield: UITextField? = UITextField(frame: 
      CGRect(x: 20, y: 20, width: self.view.frame.size.width - 40, height: 40))
    nameTextfield?.placeholder = PLACEHOLDER_TEXT
    self.view.addSubview(nameTextfield!)
    
    // create the textView
    nameTextView = UITextView(frame: CGRect(x: 20, y: 80, 
      width: self.view.frame.size.width - 40, height: 40))
    self.view.addSubview(nameTextView!)

    applyPlaceholderStyle(nameTextView!, placeholderText: PLACEHOLDER_TEXT)
}

And we need to implement it:

func applyPlaceholderStyle(aTextview: UITextView, placeholderText: String)
{
  // make it look (initially) like a placeholder
  aTextview.textColor = UIColor.lightGrayColor()
  aTextview.text = placeholderText
}

And we’ll need a similar function to remove that styling when they start to type:

func applyNonPlaceholderStyle(aTextview: UITextView)
{
  // make it look like normal text instead of a placeholder
  aTextview.textColor = UIColor.darkTextColor()
  aTextview.alpha = 1.0
}

Not too bad so far. Run it and you’ll find that the placeholder style gets applied but it never gets changed. For that we need the text view to let us know when they’re typing.

First, make the class a UITextView delegate. Change the class declaration to add that protocol:

class ViewController: UIViewController, UITextViewDelegate

And assign the class as the delegate at the end of viewDidLoad() (or anywhere after you create nameTextField):

nameTextView?.delegate = self

Let’s breakdown some of the requirements: When the user start editing the text, the cursor shows up at the start of the field but the placeholder is still there until they type a character
So first, when they start editing the text we need to move the cursor to the start but only if the placeholder text is currently shown (otherwise the cursor would get moved even though they already have text they’ve previously typed in). We can handle that in textViewShouldBeginEditing:

func textViewShouldBeginEditing(aTextView: UITextView) -> Bool
{
  if aTextView == nameTextView && aTextView.text == PLACEHOLDER_TEXT
  {
    // move cursor to start
    moveCursorToStart(aTextView)
  }
  return true
}

Some of this code is future-proofing: we’re checking that the textView is the once that we expect even though we only have one. It’s a lot easier to get in the habit of writing checks like this one than to come back and fit all the bits that need fixing when a second text view gets added to this view controller. Right now all of the logic is fresh in our minds as we’re writing it so we won’t miss anything. If we come back in a few months and try to make those changes then it’s entirely possibly that we might forget one little detail that will make our UX just a little bit less polished.

So in textViewShouldBeginEditing, once we’re sure we’re handling the correct text view, we see if the placeholder text is there. If it is, then we want to move the cursor to the start of the text view. For this, we’ll use another helper function:

func moveCursorToStart(aTextView: UITextView)
{
  dispatch_async(dispatch_get_main_queue(), {
    aTextView.selectedRange = NSMakeRange(0, 0);
  })
}

You’re going to have to trust me a little on this one. You can try it without dispatch_async and you’ll find that it does nothing. It seems that we need to allow for textViewShouldBeginEditing to finish running before setting the selectedRange for the textView. Setting the range to position = 0 and length = 0 puts it at the start of the text view.

Two requirements down and three to go. Fortunately, we can handle off of them in the same delegate method: textView:shouldChangeTextInRange:replacementText:

This method gets called when they try to change the text but before the change gets applied. It allows you to cancel their action which is handy for things like applying a max length to the text view or doing special handling to line breaks or certain characters.

The 3rd requirement should be pretty easy to handle: Once they type a character in the field (i.e., when it has contents) the placeholder text disappears and the text color gets darker.

func textView(textView: UITextView, shouldChangeTextInRange range: NSRange, replacementText text: String) -> Bool { 
  // remove the placeholder text when they start typing
  // first, see if the field is empty
  // if it's not empty, then the text should be black and not italic
  // BUT, we also need to remove the placeholder text if that's the only text
  let newLength = textView.text.utf16.count + text.utf16.count - range.length
  if newLength > 0 // have text, so don't show the placeholder
  {
    // check if the only text is the placeholder and remove it if needed
    if textView == nameTextView && textView.text == PLACEHOLDER_TEXT
    {
      applyNonPlaceholderStyle(textView)
      textView.text = ""
    }
  }
  return true
}

If it’s not obvious, text.utf16.count gets the length of the string. We’re assuming no special characters for simplicity.

Run and test it now: the placeholder will disappear when you start typing but never re-appear. That might be good enough for some devs but we want to get this experience just right.

The 4th requirement: If they delete all of the text in the field, the placeholder re-appears and the cursor is at the start of the field (if they’re still editing it). We’ll need to add an else clause to that delegate method to handle the case when there’s no text in the field:

func textView(textView: UITextView, shouldChangeTextInRange range: NSRange, replacementText text: String) -> Bool {
  // remove the placeholder text when they start typing
  // first, see if the field is empty
  // if it's not empty, then the text should be black and not italic
  // BUT, we also need to remove the placeholder text if that's the only text
  // if it is empty, then the text should be the placeholder
  let newLength = textView.text.utf16.count + text.utf16.count - range.length
  if newLength > 0 // have text, so don't show the placeholder
  {
    // check if the only text is the placeholder and remove it if needed
    if textView == nameTextView && textView.text == PLACEHOLDER_TEXT
    {
      applyNonPlaceholderStyle(textView)
      textView.text = ""
    }
    return true
  }
  else  // no text, so show the placeholder
  {
    applyPlaceholderStyle(textView, placeholderText: PLACEHOLDER_TEXT)
    moveCursorToStart(textView)
    return false
  }
}

Run and try that. Compare it to the textfield. Notice that if you tap the delete key while the placeholder is shown then the placeholder will become dark but stay in the field. That’s not right. I admit that it wasn’t until playing with it that I figured out the last requirement: If they tap the delete key while the placeholder is shown, nothing happens (i.e., the placeholder text stays visible). Sometimes that’s how it goes.

To fix that, we need to check for the special case that the replacementText is empty (delete key) and the field contains the placeholder text:

func textView(textView: UITextView, shouldChangeTextInRange range: NSRange, replacementText text: String) -> Bool {
  // remove the placeholder text when they start typing
  // first, see if the field is empty
    // if it's not empty, then the text should be black and not italic
    // BUT, we also need to remove the placeholder text if that's the only text
    // if it is empty, then the text should be the placeholder
    let newLength = textView.text.utf16.count + text.utf16.count - range.length
    if newLength > 0 // have text, so don't show the placeholder
    {
      // check if the only text is the placeholder and remove it if needed
      // unless they've hit the delete button with the placeholder displayed
      if textView == nameTextView && textView.text == PLACEHOLDER_TEXT
      {
        if text.utf16.count == 0 // they hit the back button
        {
          return false // ignore it
        }
        applyNonPlaceholderStyle(textView)
        textView.text = ""
      }
      return true
    }
    else  // no text, so show the placeholder
    {
      applyPlaceholderStyle(textView, placeholderText: PLACEHOLDER_TEXT)
      moveCursorToStart(textView)
      return false
    }
  }

That’s it. We’ve managed to bring some UI niceness to a component that doesn’t usually get that special touch. As a challenge, you might want to try:

  • Styling the text view to match the text field more closely in font size and positioning
  • Limiting the length of the text in the text field (for extra point, limit it to a certain number of lines instead of characters)
  • Having the return key move them to the next field or dismiss the keyboard

Grab the demo code on GitHub: UITextFieldPlaceholderDemo.