Add a height-flexible placeholder to your UITextView
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
}
}
(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!