Add a height-flexible placeholder to your UITextView

Add a height-flexible placeholder to your UITextView
Photo by Nick Adams / Unsplash

Once you have tasted SwiftUI, this kind of UIKit lacking makes you wish you could refactor a client's whole codebase at a snap from your fingers. At least the UI side of it. But, alas, there is no magic spell for that. Yet.

What kind of lacking? UITextView doesn't have a placeholder property, and no sensible way of setting one up is provided.

So, how do we do it? While some would suggest setting the UITextView's text property to your desired placeholder, add changing the text's style back and forth, I prefer the floating placeholder approach. Mainly because I prefer something self-contained and easily reusable.

So, let's make just that, shall we?

Add the placeholder UILabel

We create a new UILabel, and add it as one of our UITextView's subviews. In order to make it easily reusable, we add this in a UITextView extension.

To keep things simple for the rest of our app, we'll add a computed placeholder string property, used to set & retrieve our placeholder text. This will take care of creating our placeHolderLabel if we try to set some placeholder text and the label doesn't already exist.

import UIKit

extension UITextView {

    public var placeholder: String? {
        get {
            self.placeholderLabel?.text
        }
        set {
            if let placeholderLabel = placeholderLabel {
                placeholderLabel.text = newValue
            } else {
                self.addPlaceholderLabel().text = newValue
            }
        }
    }

    private var placeholderLabel: UILabel? {
        get {
            self.viewWithTag(placeholderLabelViewTag) as? UILabel
        }
    }

    private var placeholderLabelViewTag: Int {
        100
    }

    fileprivate func addPlaceholderLabel() -> UILabel {
        let newPlaceholderLabel = UILabel()
        self.setPlaceholderLabelTextConfig(label: newPlaceholderLabel)
        self.addSubview(newPlaceholderLabel)
        return newPlaceholderLabel
    }

    fileprivate func setPlaceholderLabelTextConfig(label: UILabel) {
        label.tag = placeholderLabelViewTag
    }
}

Configure our UILabel's text appearance

Our placeholder label's text should have a distinct appearance

import UIKit

extension UITextView {

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

    private var placeholderLabel: UILabel? {
        get {
            self.viewWithTag(placeholderLabelViewTag) as? UILabel
        }
    }

    private var defaultPlaceholderTextColor: UIColor {
        if #available(iOS 13.0, *) {
            return UIColor.systemGray3
        } else {
            return UIColor.lightGray
        }
    }

    private var placeholderLabelViewTag: Int {
        100
    }

    fileprivate func addPlaceholderLabel() -> UILabel {
        let newPlaceholderLabel = UILabel()
        self.setPlaceholderLabelTextConfig(label: newPlaceholderLabel)
        self.addSubview(newPlaceholderLabel)
        return newPlaceholderLabel
    }

    fileprivate func setPlaceholderLabelTextConfig(label: UILabel) {
        label.font = self.font
        label.lineBreakMode = .byWordWrapping
        label.allowsDefaultTighteningForTruncation = true
        label.adjustsFontSizeToFitWidth = true
        label.numberOfLines = 0
        label.tag = placeholderLabelViewTag
        label.isHidden = !self.text.isEmpty
        label.textColor = defaultPlaceholderTextColor
    }
}

Hide the placeholder when it is not needed

(ie when the UITextView actually displays any text)

import UIKit

extension UITextView {

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

    private var placeholderLabel: UILabel? {
        get {
            self.viewWithTag(placeholderLabelViewTag) as? UILabel
        }
    }

    private var defaultPlaceholderTextColor: UIColor {
        if #available(iOS 13.0, *) {
            return UIColor.systemGray3
        } else {
            return UIColor.lightGray
        }
    }

    private var placeholderLabelViewTag: Int {
        100
    }

    fileprivate func addPlaceholderLabel() -> UILabel {
        let newPlaceholderLabel = UILabel()
        self.setPlaceholderLabelTextConfig(label: newPlaceholderLabel)
        self.addSubview(newPlaceholderLabel)
        self.setupPlaceholderTextViewObserver()
        return newPlaceholderLabel
    }

    fileprivate func setPlaceholderLabelTextConfig(label: UILabel) {
        label.font = self.font
        label.lineBreakMode = .byWordWrapping
        label.allowsDefaultTighteningForTruncation = true
        label.adjustsFontSizeToFitWidth = true
        label.numberOfLines = 0
        label.tag = placeholderLabelViewTag
        label.isHidden = !self.text.isEmpty
        label.textColor = defaultPlaceholderTextColor
    }

    fileprivate func setupPlaceholderTextViewObserver() {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(textViewDidChange),
            name: UITextView.textDidChangeNotification,
            object: nil
        )
    }

    @objc public func textViewDidChange() {
        self.placeholderLabel?.isHidden = !self.text.isEmpty
    }
}

Set its size programmatically using AutoLayout constraints

So that it correctly fits over our UITextView

import UIKit

extension UITextView {

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

    private var placeholderLabel: UILabel? {
        get {
            self.viewWithTag(placeholderLabelViewTag) as? UILabel
        }
    }

    private var defaultPlaceholderTextColor: UIColor {
        if #available(iOS 13.0, *) {
            return UIColor.systemGray3
        } else {
            return UIColor.lightGray
        }
    }

    private var placeholderLabelViewTag: Int {
        100
    }

    fileprivate func addPlaceholderLabel() -> UILabel {
        let newPlaceholderLabel = UILabel()
        self.setPlaceholderLabelTextConfig(label: newPlaceholderLabel)
        self.addSubview(newPlaceholderLabel)
        self.setPlaceholderLabelConstraints(label: newPlaceholderLabel)
        self.setupPlaceholderTextViewObserver()
        return newPlaceholderLabel
    }

    fileprivate func setPlaceholderLabelTextConfig(label: UILabel) {
        label.font = self.font
        label.lineBreakMode = .byWordWrapping
        label.allowsDefaultTighteningForTruncation = true
        label.adjustsFontSizeToFitWidth = true
        label.numberOfLines = 0
        label.tag = placeholderLabelViewTag
        label.isHidden = !self.text.isEmpty
        label.textColor = defaultPlaceholderTextColor
    }

    fileprivate func setPlaceholderLabelConstraints(label: UILabel) {
        label.translatesAutoresizingMaskIntoConstraints = false
        let placeholderLabelPadding = self.textContainer.lineFragmentPadding
        let placeholderConstraints = [
            NSLayoutConstraint(item: label, attribute: .left, relatedBy: .equal, toItem: self, attribute: .left, multiplier: 1, constant: placeholderLabelPadding + 9),
            NSLayoutConstraint(item: label, attribute: .right, relatedBy: .equal, toItem: self, attribute: .right, multiplier: 1, constant: -(placeholderLabelPadding + 9)),
            NSLayoutConstraint(item: label, attribute: .top, relatedBy: .equal, toItem: self, attribute: .top, multiplier: 1, constant: placeholderLabelPadding),
            NSLayoutConstraint(item: label, attribute: .bottom, relatedBy: .lessThanOrEqual, toItem: self, attribute: .bottom, multiplier: 1, constant: -placeholderLabelPadding)
        ]
        for constraint in placeholderConstraints {
            constraint.priority = .required
        }
        NSLayoutConstraint.activate(placeholderConstraints)
    }

    fileprivate func setupPlaceholderTextViewObserver() {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(textViewDidChange),
            name: UITextView.textDidChangeNotification,
            object: nil
        )
    }

    @objc public func textViewDidChange() {
        self.placeholderLabel?.isHidden = !self.text.isEmpty
    }
}
How to add constraints programmatically using Swift
I’m trying to figure this out since last week without going any step further. Ok, so I need to apply some constraints programmatically in Swift to a UIView using this code: var new_view:UIView! = ...
If you, like me, sometimes forget how it's done, here's a refresher!

(optionally) Simplify configuring our placeholder should we have unexpected needs

Perhaps we may, at some point, want a different font, or linebreak mode, or event colour, for our placeholder, instead of using the UITextView or the (sensible) defaults we've used. To avoid ending up creating some random custom UITextView logic in different areas of our app, let's provide a simple method to customize our placeholder.

import UIKit

extension UITextView {

    public struct PlaceholderConfig {
        let font: UIFont?
        let lineBreakMode: NSLineBreakMode
        let allowsDefaultTighteningForTruncation: Bool
        let adjustsFontSizeToFitWidth: Bool
        let numberOfLines: Int
        let textColor: UIColor?
    }

    /// The UITextView placeholder text
    public var placeholder: String? {
        get {
            self.placeholderLabel?.text
        }
        set {
            (self.placeholderLabel ?? addPlaceholderLabel())
        }
    }

    public func configPlaceholder(placeholder: String?, placeholderConfig: PlaceholderConfig?) {
        self.addPlaceholderLabel(placeholderConfig: placeholderConfig).text = placeholder
    }

    private var placeholderLabel: UILabel? {
        get {
            self.viewWithTag(placeholderLabelViewTag) as? UILabel
        }
    }

    private var defaultPlaceholderTextColor: UIColor {
        if #available(iOS 13.0, *) {
            return UIColor.systemGray3
        } else {
            return UIColor.lightGray
        }
    }

    private var defaultPlaceholderConfig: PlaceholderConfig {
        PlaceholderConfig(
            font: self.font,
            lineBreakMode: .byWordWrapping,
            allowsDefaultTighteningForTruncation: true,
            adjustsFontSizeToFitWidth: true,
            numberOfLines: 0,
            textColor: self.defaultPlaceholderTextColor
        )
    }

    private var placeholderLabelViewTag: Int {
        100
    }

    fileprivate func addPlaceholderLabel(placeholderConfig: PlaceholderConfig? = nil) -> UILabel {
        let newPlaceholderLabel = UILabel()
        self.setPlaceholderLabelTextConfig(label: newPlaceholderLabel, placeholderConfig: placeholderConfig)
        self.addSubview(newPlaceholderLabel)
        self.setPlaceholderLabelConstraints(label: newPlaceholderLabel)
        self.setupPlaceholderTextViewObserver()
        return newPlaceholderLabel
    }

    fileprivate func setPlaceholderLabelTextConfig(label: UILabel, placeholderConfig: PlaceholderConfig?) {
        let configForPlaceholder = placeholderConfig ?? defaultPlaceholderConfig
        label.font = configForPlaceholder.font
        label.lineBreakMode = configForPlaceholder.lineBreakMode
        label.allowsDefaultTighteningForTruncation = configForPlaceholder.allowsDefaultTighteningForTruncation
        label.adjustsFontSizeToFitWidth = configForPlaceholder.adjustsFontSizeToFitWidth
        label.numberOfLines = configForPlaceholder.numberOfLines
        label.tag = placeholderLabelViewTag
        label.isHidden = !self.text.isEmpty
        label.textColor = configForPlaceholder.textColor
    }

    fileprivate func setPlaceholderLabelConstraints(label: UILabel) {
        label.translatesAutoresizingMaskIntoConstraints = false
        let placeholderLabelPadding = self.textContainer.lineFragmentPadding
        let placeholderConstraints = [
            NSLayoutConstraint(item: label, attribute: .left, relatedBy: .equal, toItem: self, attribute: .left, multiplier: 1, constant: placeholderLabelPadding + 9),
            NSLayoutConstraint(item: label, attribute: .right, relatedBy: .equal, toItem: self, attribute: .right, multiplier: 1, constant: -(placeholderLabelPadding + 9)),
            NSLayoutConstraint(item: label, attribute: .top, relatedBy: .equal, toItem: self, attribute: .top, multiplier: 1, constant: placeholderLabelPadding),
            NSLayoutConstraint(item: label, attribute: .bottom, relatedBy: .lessThanOrEqual, toItem: self, attribute: .bottom, multiplier: 1, constant: -placeholderLabelPadding)
        ]
        for constraint in placeholderConstraints {
            constraint.priority = .required
        }
        NSLayoutConstraint.activate(placeholderConstraints)
    }

    fileprivate func setupPlaceholderTextViewObserver() {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(textViewDidChange),
            name: UITextView.textDidChangeNotification,
            object: nil
        )
    }

    @objc public func textViewDidChange() {
        self.placeholderLabel?.isHidden = !self.text.isEmpty
    }
}

Epilogue

And that's it! Hope you liked it, and that it will prove useful to you! Enjoy your day, and, as usual, should you encounter any issues or have any suggestions for improvements, reach out to me on Twitter!

Happy Pineapple
Photo by Evi Radauscher / Unsplash