Auto-Growing NSTextField

I ran into a situation with a window that is movable by its background and a text field inside it. The text field draws neither border nor background, so to the user, it looks like an input field directly on the window’s background. Much like the Spotlight search box.

You can hover with the mouse over the empty macOS Spotlight widget and drag the window around anywhere

Even when a window’s isMovableByWindowBackground is enabled, a NSTextField captures click and drag events, and it changes the pointer to a text insertion variant (NSCursor.iBeam). All that is weird when you don’t know there’s a text field at that point. And you cannot know how large the text field is if nothing is drawn there. When the background is gone, this feels pretty weird.

One could subclass NSTextView and change mouseDownCanMoveWindow to return true. That would let some drag operations through, but even for controls that are supposed to be clickable but not draggable, like table view lists, this produced a lot of micro movements during my tests. That’s partly because I use an external mouse: the touch bar on MacBooks usually doesn’t register move/drag commands that quickly. I discarded this cheap way out for that reason, though.

My solution was to shrink the text field to fit the content.

  1. Give it a >= trailing Auto Layout constraint so it can get smaller than the window,
  2. Set the window’s contentView’s width to a constant value,
  3. Update NSTextField.intrinsicContentSize while the user is typing to fit the typed text.

Spotlight, by the way, doesn’t use NSTextField.placeholderString. I think Apple conditionally show/hide a label behind the text field, with isVisible wired to !String.isEmpty. For a regular placeholder to show, the text field has to be wide enough. But when the text field is as wide as the placeholder, the I-beam cursor should show when you hover over the placeholder text. With the Spotlight search box, it doesn’t.

Here’s my code for both variants: one using a placeholder, which makes the size caching a bit more complicated, and one that ignores the placeholder completely, so you have to wire a label like Spotlight does it.

Drop-in NSTextField subclass that uses the placeholder

  • It respects the placeholderString, either changed during runtime or set in Interface Builder, to affect the minimum width.
  • It adapts the intrinsicContentSize to match the visible contents of the field editor (or the placeholder, if the field editor is empty).
  • It reacts to programmatic changes of the stringValue property.
class AutoGrowingTextField: NSTextField {
    private var placeholderWidth: CGFloat? = 0

    /// Field editor inset; experimental value
    private let rightMargin: CGFloat = 5

    private var lastSize: NSSize?
    private var isEditing = false

    override func awakeFromNib() {
        super.awakeFromNib()

        if let placeholderString = self.placeholderString {
            self.placeholderWidth = sizeForProgrammaticText(placeholderString).width
        }
    }

    override var placeholderString: String? {
        didSet {
            guard let placeholderString = self.placeholderString else { return }
            self.placeholderWidth = sizeForProgrammaticText(placeholderString).width
        }
    }

    override var stringValue: String {
        didSet {
            guard !isEditing else { return }
            self.lastSize = sizeForProgrammaticText(stringValue)
        }
    }

    private func sizeForProgrammaticText(_ string: String) -> NSSize {
        let font = self.font ?? NSFont.systemFont(ofSize: NSFont.systemFontSize, weight: .regular)
        let stringWidth = NSAttributedString(
            string: string,
            attributes: [ .font : font ])
            .size().width

        // Don't use `self` to avoid cycles
        var size = super.intrinsicContentSize
        size.width = stringWidth
        return size
    }

    override func textDidBeginEditing(_ notification: Notification) {
        super.textDidBeginEditing(notification)
        isEditing = true
    }

    override func textDidEndEditing(_ notification: Notification) {
        super.textDidEndEditing(notification)
        isEditing = false
    }

    override func textDidChange(_ notification: Notification) {
        super.textDidChange(notification)
        self.invalidateIntrinsicContentSize()
    }

    override var intrinsicContentSize: NSSize {
        var minSize: NSSize {
            var size = super.intrinsicContentSize
            size.width = self.placeholderWidth ?? 0
            return size
        }

        // Use cached value when not editing
        guard isEditing,
            let fieldEditor = self.window?.fieldEditor(false, for: self) as? NSTextView
            else { return self.lastSize ?? minSize }

        // Make room for the placeholder when the text field is empty
        guard !fieldEditor.string.isEmpty else {
            self.lastSize = minSize
            return minSize
        }

        // Use the field editor's computed width when possible
        guard let container = fieldEditor.textContainer,
            let newWidth = container.layoutManager?.usedRect(for: container).width
            else { return self.lastSize ?? minSize }

        var newSize = super.intrinsicContentSize
        newSize.width = newWidth + rightMargin

        self.lastSize = newSize

        return newSize
    }
}

Variant that ignores the placeholder (so you have to add a label yourself)

  • It adapts the intrinsicContentSize to match the visible contents of the field editor.
  • It reacts to programmatic changes of the stringValue property.

You have to create a label below this text field with the placeholder value and then set up view controller wiring to conditionally hide and show the placeholder. Here’s some boilerplate to get you started, not taking into account programmatic changes to NSTextField.stringValue:

class MyViewController: NSViewController, NSTextFieldDelegate {
    @IBOutlet weak var placeholderLabel: NSTextField!
    @IBOutlet weak var textField: NSTextField!

    func controlTextDidChange(_ obj: Notification) {
        placeholderLabel.isHidden = !textField.stringValue.isEmpty
    }
}

And here’s the way simpler text field subclass:

class AutoGrowingTextField: NSTextField {
    /// Field editor inset; experimental value
    private let rightMargin: CGFloat = 5

    private var lastSize: NSSize?
    private var isEditing = false

    override var stringValue: String {
        didSet {
            guard !isEditing else { return }
            self.lastSize = sizeForProgrammaticText(stringValue)
        }
    }

    private func sizeForProgrammaticText(_ string: String) -> NSSize {
        let font = self.font ?? NSFont.systemFont(ofSize: NSFont.systemFontSize, weight: .regular)
        let stringWidth = NSAttributedString(
            string: string,
            attributes: [ .font : font ])
            .size().width

        // Don't use `self` to avoid cycles
        var size = super.intrinsicContentSize
        size.width = stringWidth
        return size
    }

    override func textDidBeginEditing(_ notification: Notification) {
        super.textDidBeginEditing(notification)
        isEditing = true
    }

    override func textDidEndEditing(_ notification: Notification) {
        super.textDidEndEditing(notification)
        isEditing = false
    }

    override func textDidChange(_ notification: Notification) {
        super.textDidChange(notification)
        self.invalidateIntrinsicContentSize()
    }

    override var intrinsicContentSize: NSSize {
        var minSize: NSSize {
            var size = super.intrinsicContentSize
            size.width = 0
            return size
        }

        guard isEditing,
            let fieldEditor = self.window?.fieldEditor(false, for: self) as? NSTextView,
            let container = fieldEditor.textContainer,
            let newWidth = container.layoutManager?.usedRect(for: container).width
            else { return self.lastSize ?? minSize }

        var newSize = super.intrinsicContentSize
        newSize.width = newWidth + rightMargin

        self.lastSize = newSize

        return newSize
    }
}