Text Field Cursor Color Goes BRRR
March 1, 2023
Chronometer
TL;DR

In order to change SwiftUI text field insertion point color on macOS, you need some help from AppKit and Objective-C.

Heading linkContext

So I was minding my own business and hacking my way through the invoice generator app I'm working on, and then this thought struck me in the head:

Damn, this text field cursor color looks way off. It's almost invisible on this dark background. Oh well, whatever. I should be able to customize it to fit my needs.

Famous last words, eh? Okay, well, since I was working with SwiftUI on the macOS app, I knew it would not be straightforward.

Heading linkThe Crime Scene

Since I won't add thousands of lines of code from my invoice app here, let's create a simplified crime scene with the following evidence:

In SwiftUI world, it would look something like this:

struct ContentView: View {
    @State var text1 = ""
    @State var text2 = ""
    
    var body: some View {
        VStack {
            TextField("TextField 1", text: $text1)
            TextField("TextField 2", text: $text2)
        }
        .padding()
        .textFieldStyle(.roundedBorder)
    }
}

Since we now have the crime scene, let's look at the prime suspects.

Heading linkSuspect 1: The Expected Way

In a beautiful, colorful, and magical world where we all live in peace, we would expect something like tint(.red) to do the trick:

struct ContentView: View {
    @State var text1 = ""
    @State var text2 = ""
    
    var body: some View {
        VStack {
            TextField("TextField 1", text: $text1)
                .tint(.yellow)
            
            TextField("TextField 2", text: $text2)
                .tint(.red)
        }
        .padding()
        .textFieldStyle(.roundedBorder)
    }
}

If you run the code above, you will see something like this:

Either I am color-blind, or this didn't do the trick. For now, I will assume that it is the latter.

It looks like we didn't go through a long and painful evolution just to achieve things we want in life with a single line of code.

Heading linkSuspect 2: The AppKit Way

So the SwiftUI™ way is not working. What are we going to do? When faced with a problem, it's common to dig a bit deeper in order to understand it better.

It's not a big secret that under the shiny and beautiful mask of SwiftUI, there is someone hiding. Someone with experience and battle-tested scars. On macOS, this someone is none other than our beloved old friend - AppKit.

The ability to somewhat easily (for now) go a level deeper is one of the beautiful parts of SwiftUI. In a way, SwiftUI says: "Yes, I might be missing something, but here is a cheat code that will let you go for a wild and unsafe ride."

To go this level deeper, we could manually wrap TextField into our custom NSViewRepresentable implementation or use something like Introspect. To make this example simple, let's use Introspect library.

Since Introspect gives us closure to access underlying NSTextField, we can write a neat little extension on View which:

  1. Retrieves field editor associated with our text field
  2. Changes insertionPointColor on that field editor

It could look something like this:

extension View {
    func textFieldInsertionPointColor(_ color: Color) -> some View {
        self
            #if os(macOS)
            // Access underlying NSTextField
            .introspectTextField { textField in
                // Extract field editor associated with our NSTextField
                let fieldEditor = textField.window?.fieldEditor(true, for: textField) as? NSTextView
                // Change insertion point color
                fieldEditor?.insertionPointColor = NSColor(color)
            }
            #endif
    }
}

Now that we have an extension that adds functionality to customize insertion point color, we can use it in our previous code sample:

struct ContentView: View {
    @State var text1 = ""
    @State var text2 = ""
    
    var body: some View {
        VStack {
            TextField("TextField 1", text: $text1)
                .textFieldInsertionPointColor(.yellow)
            
            TextField("TextField 2", text: $text2)
                .textFieldInsertionPointColor(.red)
        }
        .padding()
        .textFieldStyle(.roundedBorder)
    }
}

Pretty simple and clean, right? Some of you might even raise your victory flag.

However, I have some bad news for you. It won't work as expected when you have more than one TextField on the screen. You will notice something like the following:

Even though two different colors are set for these text fields, only one color is used for both of them. What gives?

If you open the documentation for fieldEditor(_:for:), you will notice the following:

The field editor is a single NSTextView object that is shared among all the controls in a window for light text-editing needs. It is automatically instantiated when needed, and it can be used however your application sees fit. Typically, the field editor is used by simple text-bearing objects—for example, an NSTextField object uses its window’s field editor to display and manipulate text. The field editor can be shared by any number of objects, and so its state may be constantly changing. Therefore, it shouldn’t be used to display text that demands sophisticated layout (for this you should create a dedicated NSTextView object).

What happens is that even though you have unique NSTextField instances, NSTextView is shared among all of them. So when you update the color on one text field, it will update the color on the shared NSTextView instance resulting in a single color winning the colorful battle.

Heading linkSuspect 3: The Objective-C Way

Oh, my old friend... It seems that I still can't walk without your strongly referencing hand.

We know the following two facts:

  1. NSTextField instances are unique
  2. NSTextView instance is shared among them

The question is: what can we do with these facts, and how Objective-C has anything to do with it? Well, for starters, we can use NSTextField uniqueness to utilize NSTextField as our model layer. After that, we can try to find a place where we could use our model layer knowledge in order to update insertionPointColor.

Let's start by adding a custom property on NSTextField. Property? On external class? Property that is not computed? Swift? Yes... You are right... Our old friend Objective-C and its runtime are coming to help us here:

extension NSTextField {
    // Create a key used to access the associated object
    private struct Keys {
        static var customInsertionPointColor: UInt8 = 0
    }
    
    // Create a computed property that uses associated object APIs to get and set the color
    var customInsertionPointColor: NSColor? {
        get {
            objc_getAssociatedObject(self, &Keys.customInsertionPointColor) as? NSColor
        }
        set {
            objc_setAssociatedObject(self, &Keys.customInsertionPointColor, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
    }
}

Now that we have a place that can be used to store our insertion point color, we can try to find a place where to put our hook.

My first intuition was to go with NSResponder and hook into validateProposedFirstResponder(_:for:), changing insertionPointColor whenever NSTextField returned true. Sadly, this doesn't work as both text fields simultaneously call it from the resetCursorRects method.

However, there is an alternative and less elegant spot for hooking up: currentEditor(). This method is called many times since different parts need to access the current editor to perform relevant UI changes. While this is not as clean, we can write the following override for currentEditor():

extension NSTextField {
    open override func currentEditor() -> NSText? {
        let editor = super.currentEditor()
        
        if
            let fieldEditor = editor as? NSTextView,
            let customInsertionPointColor
        {
            fieldEditor.insertionPointColor = customInsertionPointColor
            fieldEditor.updateInsertionPointStateAndRestartTimer(true)
        }
        
        return editor
    }
}

This will check if we have a custom insertion point color on NSTextField and use that to update the field editor. Furthermore, for a smoother transition, we will also update the point state.

With all these changes, we can update our original extension to the following:

extension View {
    func textFieldInsertionPointColor(_ color: Color) -> some View {
        self
            #if os(macOS)
            .introspectTextField { textField in
                textField.customInsertionPointColor = NSColor(color)
            }
            #endif
    }
}

If we run our original code, then we should see the following:

Insertion point color updates as intended. Our long and painful evolution wasn't a total waste of time.

Heading linkConclusion

It is sometimes pretty frustrating and tiresome to write hacks in order to achieve that missing few percent of fluid and native feeling UI in SwiftUI. It seems that usually, it's straightforward to get to 95% of perfection, but then to get closer to 99%, you need to break a few walls, dig a hole and stitch everything nicely, asking your old friends (AppKit, UIKit, Objective-C) for the help.

Nevertheless, I'm delighted with SwiftUI and where it is going, and I can say that each year reaching that 99% mark becomes easier and easier.

© Edvinas Byla, 2024

(my lovely corner on the internet™)