NSTextStorage subclass can't handle emoji characters and changes font in some cases
Asked Answered
B

3

9

I'm subclassing NSTextStorage to do some link highlighting and I've read as much as I can on the topic. Everything works fine until I type the πŸ’ emoji character.

My subclass:

private let ims = NSMutableAttributedString()

override var string: String {
    return ims.string
}

override func attributesAtIndex(location: Int, effectiveRange range: NSRangePointer) -> [String : AnyObject] {
    return ims.attributesAtIndex(location, effectiveRange: range)
}

override func replaceCharactersInRange(range: NSRange, withString str: String) {
    ims.replaceCharactersInRange(range, withString: str)
    self.edited(.EditedCharacters, range: range, changeInLength:(str as NSString).length - range.length)
}

override func setAttributes(attrs: [String : AnyObject]?, range: NSRange) {
    ims.setAttributes(attrs, range: range)
    self.edited(.EditedAttributes, range: range, changeInLength: 0)
}

Nothing complicated. Then, when entering the infamous character it switches to Courier New for some random reason:

Anything but Courier New!

Now I'm picking on the πŸ’ character, there are others that cause this maddness too. I've queried the font as I type and it goes from System > Apple Emoji > Courier New.

I've also tried setting the font from within processEditing() which semi solves the problem, It causes an extra space to be added in (not in the simulator though). And I'm hardcoding a value == bad.

Ultimate Question:

What am I doing wrong? I don't see this problem with other people's implementations where I'm certain developers have subclassed NSTextStorage.

Note: I can confirm that in objc.io's demo app the same issue is present.

Bloodthirsty answered 27/4, 2016 at 19:28 Comment(7)
Hey. Have you figured out what was going on? I am just hitting the same issue and can't find a good solution as well. – Jacquelinejacquelyn
@Jacquelinejacquelyn best I have managed is to make sure the attributedString/textStorage has a value for the NSFontAttributeName key. I make sure of this in processEditing() – Bloodthirsty
I see. I ended up forcing font I need in processEditing as well. At least it doesn't break emojis. – Jacquelinejacquelyn
Bizarrely, even a completely default NSTextView seems to have this behavior. If you type an emoji, everything that comes after becomes a red monospace font. fixAttributesInRange(range: NSRange) is where this stuff gets determined in NSTextStorage, so that's probably the best place to work around it. Unfortunately, simply eliminating any unrecognized fonts would not work in every case, since fixAttributesInRange also performs font substitution for unknown characters (as it does for emoji). – Heliogravure
Oh, I see what's going on. When you type an emoji, the font range for that emoji gets turned (by fixAttributesInRange) into Apple's emoji font (AppleColorEmoji). This means that anything typed after that emoji will also use that font (as expected with NSAttributedString attributes). However, AppleColorEmoji does not contain characters for ordinary letters, so the range for those letters, in turn, gets changed from AppleColorEmoji to monospace. If only there was a way to tell NSAttributedString to constrain a font only to a particular range and never grow it! – Heliogravure
@ShawnThroop did you ever figure this out? Implementing fixAttributes(in range: NSRange) didn't work for me. I still get _NSLayoutTreeLineFragmentRectForGlyphAtIndex invalid glyph index 1 printed in the debugger with emojis – Saturate
@Saturate in the end I added a defaultAttributes dictionary (of type [String: Any]) and within processEditing() I apply those attributes to the range value returned by paragraphRange(for: editedRange) before other attributes are applied. Within init I assign a font value created from the UIFontDescriptor API to defaultAttributes and via didSet I call edited(_: range: changeInLength:) passing in .editedAttributes for the editedMask. – Bloodthirsty
H
3

Here's my layman's understanding. Most emoji only exist in Apple's AppleColorEmoji font. When you type an emoji character, your NSTextStorage calls processEditing, which then calls fixAttributesInRange. This method ensures that any missing characters in your string are replaced with fonts that support them. If your string contains emoji, all the emoji-containing ranges will get an AppleColorEmoji font attribute.

Unfortunately, nothing stops this new font attribute from "infecting" characters typed after it. AppleColorEmoji doesn't seem to contain the usual ASCII set, so those subsequent characters get "fixed" themselves with a monospace font.

What to do about it? In my program, I want to manage the attributes for my text storage manually, since I don't want copy-and-pasted text to add new styles to my text. This means that I can simply do this:

override func setAttributes(attrs: [String : AnyObject]?, range: NSRange) {
    if self.isFixingAttributes {
        self.attributedString.setAttributes(attrs, range: range)
        self.edited(NSTextStorageEditActions.EditedAttributes, range: range, changeInLength: 0)
    }
}

override func fixAttributesInRange(range: NSRange) {
    self.isFixingAttributes = true
    super.fixAttributesInRange(range)
    self.isFixingAttributes = false
}

override func processEditing() {
    // not really fixing -- just need to make sure setAttributes follows orders
    self.isFixingAttributes = true
    self.setAttributes(nil, range: self.editedRange)
    self.setAttributes(self.dynamicType.defaultAttributes(), range: self.editedRange)
    self.isFixingAttributes = false

    super.processEditing()
}

Whenever new text is typed, I simply clear its attributes (in case any of the previously-fixed ranges "infected" it) and replace them with the default attributes. After that, super.processEditing() does its thing and fixes any new missing characters in that range (if any).

If, on the other hand, you want to be able to paste styled text into your text view, it should be possible to track your fixed ranges by comparing the before/after for fixAttributesInRange, and then preventing those styles from transferring to newly-typed text in processEditing.

Heliogravure answered 12/9, 2016 at 18:9 Comment(5)
Or, I guess, just have an "unfixed" attributed string that you actually edit, and a "display" attributed string (or some other data structure for storing attributes) that catches all the fixed attributes from fixAttributesInRange. – Heliogravure
what is self.dynamicType my NSTextStorage doesn't seem to have this – Saturate
It's deprecated: #39495521 – Heliogravure
@Saturate self.dynamicType is the older version of Swift's type(of: ) function. Similar to [self class] in Objective-C. – Bloodthirsty
Had a similar issue, where I was getting incorrect fonts (except that I wasn't using any emoji). Moving the super.processEditing() call to the end of my -processEditing implementation (instead of calling it directly at the beginning) fixed this, and I didn't need to do the isFixingAttributes step – Honan
H
2

I've investigated this issue for hours. So, in conclusion, inserting (typing or pasting) an emoji character or placing cursor after some emoji characters (e.g ☺️) was causing typing font to change to "AppleColorEmoji" and which was eventually falling back to "Courier New" when a non-emoji character is inserted. This only happens only if NSTextStorage subclass is used, otherwise typing font never changed to "AppleColorEmoji". So, we fix it by resetting typing font from AppleColorEmoji to the default font set by the developer. The fix is applied before and after inserting text. The former fixes typing font change due to cursor placed after an emoji character, and the latter fixes the typing font change due to the insertion of an emoji character (typing font changes somehow are reflected in UITextView.font parameter).

See https://github.com/CosmicMind/Material/pull/1117

class EmojiFixedTextView: UITextView {
    private var _font: UIFont?

    override var font: UIFont? {
        didSet {
            _font = font
        }
    }

    override func insertText(_ text: String) {
        fixTypingFont()
        super.insertText(text)
        fixTypingFont()
    }

    override func paste(_ sender: Any?) {
        fixTypingFont()
        super.paste(sender)
        fixTypingFont()
    }

    private func fixTypingFont() {
        let fontAttribute = NSAttributedStringKey.font.rawValue
        guard (typingAttributes[fontAttribute] as? UIFont)?.fontName == "AppleColorEmoji" else {
            return
        }

        typingAttributes[fontAttribute] = _font
    }
}
Hereld answered 18/7, 2018 at 21:3 Comment(0)
S
0

Actually turns out in my case i just had to change:

self.edited(.editedCharacters, range: range, changeInLength: str.characters.count-range.length)

to:

self.edited(.editedCharacters, range: range, changeInLength: (str as NSString).length-range.length)

It's unfortunate that getting the length of a String isn't the same as an NSString

fixAttributes(in range: NSRange) was NOT needed

Saturate answered 20/4, 2017 at 23:51 Comment(2)
This is a misleading answer because it's in regards to an entirely different question involving the differences in how Swift.String and NSString calculate string lengths. tl;dr: It's quite complicated. – Bloodthirsty
Swift String type has the 'utf16' property exactly for that reason. Just take it into the count and you wouldn't have to bridge a string into the Objective-C world – Maurene

© 2022 - 2024 β€” McMap. All rights reserved.