In order to change SwiftUI text field insertion point color on macOS, you need some help from AppKit and Objective-C.
Context
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.
The 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:
- Two text fields
SwiftUI
was used- The app was running on macOS
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.
Suspect 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.
Suspect 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:
- Retrieves field editor associated with our text field
- 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, anNSTextField
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 dedicatedNSTextView
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.
Suspect 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:
NSTextField
instances are uniqueNSTextView
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.
Conclusion
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.