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.textfor 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
UITextViewapplies bothtextContainerInsetandtextContainer.lineFragmentPadding(typically 5 points) to the text boundaries. - Listen to notification changes: If you populate or clear text programmatically (
textView.text = ""), delegate methods liketextViewDidChangedo 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.
Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.
