Search code examples
swiftmacoscocoanscolor

Why does NSColor.controlTextColor change according to background color?


I'm working on a Cocoa application and I find that as long as the font color of NSTextField is set to NSColor.controlTextColor, the font will change according to the background color of NSTextField.

For example, when I set the background color to white, the font becomes black.

But when I set the background color to black, the font turns white.

I want to define an NSColor to achieve the same effect. How to achieve it?

enter image description here enter image description here


Solution

  • If you want to pass in any color and then determine which text color would be more ideal - black or white - you first need to determine the luminance of that color (in sRGB). We can do that by converting to grayscale, and then checking the contrast with black vs white.

    Check out this neat extension that does so:

    extension NSColor {
    
        /// Determine the sRGB luminance value by converting to grayscale. Returns a floating point value between 0 (black) and 1 (white).
        func luminance() -> CGFloat {
            var colors: [CGFloat] = [redComponent, greenComponent, blueComponent].map({ value in
                if value <= 0.03928 {
                    return value / 12.92
                } else {
                    return pow((value + 0.055) / 1.055, 2.4)
                }
            })
            let red = colors[0] * 0.2126
            let green = colors[1] * 0.7152
            let blue = colors[2] * 0.0722
            return red + green + blue
        }
    
        func contrast(with color: NSColor) -> CGFloat {
            return (self.luminance() + 0.05) / (color.luminance() + 0.05)
        }
    
    }
    

    Now we can determine whether we should use black or white as our text by checking the contrast between our background color with black and comparing it to the contrast with white.

    // Background color for whatever UI component you want.
    let backgroundColor = NSColor(red: 0.5, green: 0.8, blue: 0.2, alpha: 1.0)
    // Contrast of that color w/ black.
    let blackContrast = backgroundColor.contrast(with: NSColor.black.usingColorSpace(NSColorSpace.sRGB)!)
    // Contrast of that color with white.
    let whiteContrast = backgroundColor.contrast(with: NSColor.white.usingColorSpace(NSColorSpace.sRGB)!)
    // Ideal color of the text, based on which has the greater contrast.
    let textColor: NSColor = blackContrast > whiteContrast ? .black : .white
    

    In this case above, the backgroundColor produces a contrast of 10.595052467245562 with black and 0.5045263079640744 with white. So clearly, we should use black as our font color!

    The value for black can be corroborated here.


    EDIT: The logic for the .controlTextColor is going to be beneath the surface of the API that Apple provides and beyond me. It has to do with the user's preferences, etc. and may operate on views during runtime (i.e. by setting .controlTextColor, you might be flagging a view to check for which textColor is more legible during runtime and applying it).

    TL;DR: I don't think you have the ability to achieve the same effect as .controlTextColor with an NSColor subclass.

    Here's an example of a subclassed element that uses its backgroundColor to determine the textColor, however, to achieve that same effect. Depending on what backgroundColor you apply to the class, the textColor will be determined by it.

    class ContrastTextField: NSTextField {
    
        override var textColor: NSColor? {
            set {}
            get {
                if let background = self.layer?.backgroundColor {
                    let color = NSColor(cgColor: background)!.usingColorSpace(NSColorSpace.sRGB)!
                    let blackContrast = color.contrast(with: NSColor.black.usingColorSpace(NSColorSpace.sRGB)!)
                    let whiteContrast = color.contrast(with: NSColor.white.usingColorSpace(NSColorSpace.sRGB)!)
                    return blackContrast > whiteContrast ? .black : .white
                }
                return NSColor.black
            }
        }
    
    }
    

    Then you can implement with:

    let textField = ContrastTextField()
    textField.wantsLayer = true
    textField.layer?.backgroundColor = NSColor.red.cgColor
    textField.stringValue = "test"
    

    Will set your textColor depending on the layer's background.