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.

Shawn Throop
  • 1,281
  • 1
  • 13
  • 28
  • 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. – Nekto Jun 20 '16 at 23:17
  • 1
    @Nekto 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()` – Shawn Throop Jun 21 '16 at 09:26
  • I see. I ended up forcing font I need in `processEditing` as well. At least it doesn't break emojis. – Nekto Jun 21 '16 at 17:04
  • 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). – Archagon Sep 12 '16 at 16:59
  • 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! – Archagon Sep 12 '16 at 17:16
  • @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 – tettoffensive Apr 20 '17 at 23:41
  • @tettoffensive 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. – Shawn Throop Apr 20 '17 at 23:54

3 Answers3

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.

Archagon
  • 2,470
  • 2
  • 25
  • 38
  • 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`. – Archagon Sep 17 '16 at 00:11
  • what is `self.dynamicType` my NSTextStorage doesn't seem to have this – tettoffensive Apr 20 '17 at 22:55
  • 1
    It's deprecated: http://stackoverflow.com/questions/39495021/dynamictype-is-deprecated-use-typeof-instead – Archagon Apr 20 '17 at 23:48
  • @tettoffensive `self.dynamicType` is the older version of Swift's `type(of: )` function. Similar to `[self class]` in Objective-C. – Shawn Throop Apr 20 '17 at 23:57
  • 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 – lukas Nov 19 '22 at 18:39
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
    }
}
Orkhan Alikhanov
  • 9,122
  • 3
  • 39
  • 60
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

tettoffensive
  • 664
  • 7
  • 24
  • 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](https://oleb.net/blog/2014/07/swift-strings/). – Shawn Throop Apr 21 '17 at 00:06
  • 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 – Vitalii Vashchenko Sep 10 '17 at 14:42