Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Color attribute is ignored in NSAttributedString with NSLinkAttributeName

In an NSAttributedString, a range of letters has a link attribute and a custom color attribute.

In Xcode 7 with Swift 2, it works:

enter image description here

In Xcode 8 with Swift 3, the custom attributed color for the link is always ignored (it should be orange in the screenshot).

enter image description here

Here's the code for testing.

Swift 2, Xcode 7:

import Cocoa
import XCPlayground

let text = "Hey @user!"

let attr = NSMutableAttributedString(string: text)
let range = NSRange(location: 4, length: 5)
attr.addAttribute(NSForegroundColorAttributeName, value: NSColor.orangeColor(), range: range)
attr.addAttribute(NSLinkAttributeName, value: "http://somesite.com/", range: range)

let tf = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 50))
tf.allowsEditingTextAttributes = true
tf.selectable = true
tf.stringValue = text
tf.attributedStringValue = attr

XCPlaygroundPage.currentPage.liveView = tf

Swift 3, Xcode 8:

import Cocoa
import PlaygroundSupport

let text = "Hey @user!"

let attr = NSMutableAttributedString(string: text)
let range = NSRange(location: 4, length: 5)
attr.addAttribute(NSForegroundColorAttributeName, value: NSColor.orange, range: range)
attr.addAttribute(NSLinkAttributeName, value: "http://somesite.com/", range: range)

let tf = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 50))
tf.allowsEditingTextAttributes = true
tf.isSelectable = true
tf.stringValue = text
tf.attributedStringValue = attr

PlaygroundPage.current.liveView = tf

I've sent a bug report to Apple, but in the meantime if someone has an idea for a fix or workaround in Xcode 8, that would be great.

like image 573
Eric Aya Avatar asked Oct 07 '16 23:10

Eric Aya


1 Answers

This answer is not a fix for the issue of NSLinkAttributeName ignoring custom colors, it's an alternative solution for having colored clickable words in NSAttributedString.


With this workaround we don't use NSLinkAttributeName at all, since it forces a style we don't want.

Instead, we use custom attributes, and we subclass the NSTextField/NSTextView to detect the attributes under the mouse click and act accordingly.

There's several constraints, obviously: you have to be able to subclass the field/view, to override mouseDown, etc, but "it works for me" while waiting for a fix.

When preparing your NSMutableAttributedString, where you would have set an NSLinkAttributeName, set the link as an attribute with a custom key instead:

theAttributedString.addAttribute("CUSTOM", value: theLink, range: theLinkRange)
theAttributedString.addAttribute(NSForegroundColorAttributeName, value: NSColor.orange, range: theLinkRange)
theAttributedString.addAttribute(NSCursorAttributeName, value: NSCursor.arrow(), range: theLinkRange)

The color and content for the link is set. Now we have to make it clickable.

For this, subclass your NSTextView and override mouseDown(with event: NSEvent).

We will get the location of the mouse event in the window, find the character index in the text view at that location, and ask for the attributes of the character at this index in the text view's attributed string.

class MyTextView: NSTextView {

    override func mouseDown(with event: NSEvent) {
        // the location of the click event in the window
        let point = self.convert(event.locationInWindow, from: nil)
        // the index of the character in the view at this location
        let charIndex = self.characterIndexForInsertion(at: point)
        // if we are not outside the string...
        if charIndex < super.attributedString().length {
            // ask for the attributes of the character at this location
            let attributes = super.attributedString().attributes(at: charIndex, effectiveRange: nil)
            // if the attributes contain our key, we have our link
            if let link = attributes["CUSTOM"] as? String {
                // open the link, or send it via delegate/notification
            }
        }
        // cascade the event to super (optional)
        super.mouseDown(with: event)
    }

}

That's it.

In my case I needed to customize different words with different colors and link types, so instead of passing just the link as a string I pass a struct containing the link and additional meta information, but the idea is the same.

If you have to use an NSTextField instead of an NSTextView, it's a bit trickier to find the click event location. A solution is to create an NSTextView inside the NSTextField and from there use the same technique as before.

class MyTextField: NSTextField {

    var referenceView: NSTextView {
        let theRect = self.cell!.titleRect(forBounds: self.bounds)
        let tv = NSTextView(frame: theRect)
        tv.textStorage!.setAttributedString(self.attributedStringValue)
        return tv
    }

    override func mouseDown(with event: NSEvent) {
        let point = self.convert(event.locationInWindow, from: nil)
        let charIndex = referenceView.textContainer!.textView!.characterIndexForInsertion(at: point)
        if charIndex < self.attributedStringValue.length {
            let attributes = self.attributedStringValue.attributes(at: charIndex, effectiveRange: nil)
            if let link = attributes["CUSTOM"] as? String {
                // ...
            }
        }
        super.mouseDown(with: event)
    }

}
like image 181
Eric Aya Avatar answered Oct 24 '22 07:10

Eric Aya