How to Add Placeholder Text to UITextView on iOS

How to Add Placeholder Text to UITextView on iOS

Written by

in

Quick answer: Unlike UITextField, UITextView does not have a native placeholder property in UIKit. The cleanest solution is to add a UILabel subview aligned to the text container insets and toggle its isHidden state inside UITextViewDelegate or via a subclass observing text changes.

Why this happens

UITextField provides a built-in placeholder property, but UITextView does not. In UIKit, UITextView is designed for multi-line, scrollable text driven by TextKit. Apple never exposed a native placeholder API on the class.

Because of this limitation, developers must build their own mechanism to display placeholder hints. If you try to access textView.placeholder, the compiler throws an error stating that UITextView has no member 'placeholder'.

Two common strategies exist: toggling the text and color directly inside the UITextView, or overlaying a UILabel that hides when content is present. The label overlay method is significantly safer because it keeps your placeholder separate from the actual underlying text value.

How to fix it

1. Overlay a custom UILabel subclass (Recommended)

Creating a custom UITextView subclass with an embedded UILabel gives you a reusable component that mimics the native UITextField.placeholder API without cluttering your view controllers.

Create a new file named PlaceholderTextView.swift:

import UIKit

final class PlaceholderTextView: UITextView {

    private let placeholderLabel = UILabel()

    var placeholder: String? {
        get { placeholderLabel.text }
        set {
            placeholderLabel.text = newValue
            setNeedsLayout()
        }
    }

    var placeholderColor: UIColor = .tertiaryLabel {
        didSet {
            placeholderLabel.textColor = placeholderColor
        }
    }

    override var text: String! {
        didSet {
            textDidChange()
        }
    }

    override var attributedText: NSAttributedString! {
        didSet {
            textDidChange()
        }
    }

    override var font: UIFont? {
        didSet {
            placeholderLabel.font = font
            setNeedsLayout()
        }
    }

    override var textAlignment: NSTextAlignment {
        didSet {
            placeholderLabel.textAlignment = textAlignment
            setNeedsLayout()
        }
    }

    override init(frame: CGRect, textContainer: NSTextContainer?) {
        super.init(frame: frame, textContainer: textContainer)
        configurePlaceholder()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        configurePlaceholder()
    }

    private func configurePlaceholder() {
        placeholderLabel.textColor = placeholderColor
        placeholderLabel.numberOfLines = 0
        placeholderLabel.font = font ?? UIFont.preferredFont(forTextStyle: .body)
        addSubview(placeholderLabel)

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(textDidChange),
            name: UITextView.textDidChangeNotification,
            object: self
        )
    }

    @objc private func textDidChange() {
        placeholderLabel.isHidden = !text.isEmpty
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        let lineFragmentPadding = textContainer.lineFragmentPadding
        let horizontalInset = textContainerInset.left + lineFragmentPadding
        let verticalInset = textContainerInset.top

        let availableWidth = bounds.width - (horizontalInset + textContainerInset.right + lineFragmentPadding)
        let targetSize = placeholderLabel.sizeThatFits(CGSize(width: availableWidth, height: .greatestFiniteMagnitude))

        placeholderLabel.frame = CGRect(
            x: horizontalInset,
            y: verticalInset,
            width: availableWidth,
            height: targetSize.height
        )
    }

    deinit {
        NotificationCenter.default.removeObserver(self)
    }
}

To use this subclass, assign the custom class in Interface Builder or initialize it in code:

let textView = PlaceholderTextView()
textView.placeholder = "Write your review here..."
textView.font = UIFont.preferredFont(forTextStyle: .body)

This method keeps textView.text completely empty when no user input exists, preventing validation bugs in forms.

2. Add an inline UILabel with UITextViewDelegate

If you only need a placeholder on a single screen and prefer not to introduce a subclass, position a UILabel directly on top of the text view in your view controller and listen for delegate callbacks.

import UIKit

final class NoteEditorViewController: UIViewController, UITextViewDelegate {

    @IBOutlet private weak var textView: UITextView!
    private let placeholderLabel = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()

        textView.delegate = self
        setupPlaceholder()
    }

    private func setupPlaceholder() {
        placeholderLabel.text = "Enter note details..."
        placeholderLabel.font = textView.font ?? UIFont.preferredFont(forTextStyle: .body)
        placeholderLabel.textColor = .placeholderText
        placeholderLabel.translatesAutoresizingMaskIntoConstraints = false

        textView.addSubview(placeholderLabel)

        let padding = textView.textContainer.lineFragmentPadding
        NSLayoutConstraint.activate([
            placeholderLabel.topAnchor.constraint(equalTo: textView.topAnchor, constant: textView.textContainerInset.top),
            placeholderLabel.leadingAnchor.constraint(equalTo: textView.leadingAnchor, constant: textView.textContainerInset.left + padding),
            placeholderLabel.trailingAnchor.constraint(equalTo: textView.trailingAnchor, constant: -(textView.textContainerInset.right + padding))
        ])

        updatePlaceholderVisibility()
    }

    private func updatePlaceholderVisibility() {
        placeholderLabel.isHidden = !textView.text.isEmpty
    }

    func textViewDidChange(_ textView: UITextView) {
        updatePlaceholderVisibility()
    }
}

Using Auto Layout with textView.textContainer.lineFragmentPadding ensures the label aligns perfectly with where the cursor renders.

3. Swap text and text color on focus (Legacy workaround)

You can also set placeholder text directly into textView.text and change its color to light gray. When editing begins, you clear the field. When editing ends, you restore the placeholder if the text view is empty.

import UIKit

final class FeedbackViewController: UIViewController, UITextViewDelegate {

    @IBOutlet private weak var textView: UITextView!
    private let placeholderString = "Share your feedback..."

    override func viewDidLoad() {
        super.viewDidLoad()
        textView.delegate = self
        resetPlaceholder()
    }

    private func resetPlaceholder() {
        textView.text = placeholderString
        textView.textColor = .placeholderText
    }

    func textViewDidBeginEditing(_ textView: UITextView) {
        if textView.textColor == .placeholderText {
            textView.text = nil
            textView.textColor = .label
        }
    }

    func textViewDidEndEditing(_ textView: UITextView) {
        if textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
            resetPlaceholder()
        }
    }
}

Warning: If your app inspects textView.text before editing starts (e.g., verifying form completion), you must remember to check whether textView.textColor == .placeholderText. Failing to do so causes the placeholder string itself to be saved or submitted as real user data.

How to prevent it

  • Never treat placeholder text as user input: Avoid relying solely on textView.text for placeholders when building form submission logic or saving models to persistent stores.
  • Account for line fragment padding: When laying out an overlay label, remember that UITextView applies both textContainerInset and textContainer.lineFragmentPadding (typically 5 points) to the text boundaries.
  • Listen to notification changes: If you populate or clear text programmatically (textView.text = ""), delegate methods like textViewDidChange do not fire automatically. Update your placeholder’s visibility whenever you modify the text in code.
  • Consider SwiftUI for new projects: If you are building new interfaces with SwiftUI, TextField("Placeholder", text: $text, axis: .vertical) natively supports multi-line placeholders without any manual workarounds.

FAQ

Why doesn’t textViewDidChange fire when I set text programmatically?

UIKit only triggers UITextViewDelegate callbacks in response to direct user interaction via the keyboard. When setting textView.text in your own code, call your visibility update function manually or post UITextView.textDidChangeNotification.

How do I align the placeholder label perfectly with the cursor?

A UITextView indents content by its textContainerInset.left plus the textContainer.lineFragmentPadding. Match your label’s leading constraint or frame origin to this combined value so the cursor and label share the exact same start position.

What system color should I use for placeholders in iOS?

Use UIColor.placeholderText (available in iOS 13 and later). It automatically adapts to Light and Dark Mode and matches the standard placeholder tint used across system components.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.