Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why are emoji characters like ๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ treated so strangely in Swift strings?

The character ๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ (family with two women, one girl, and one boy) is encoded as such:

U+1F469 WOMAN,
โ€U+200D ZWJ,
U+1F469 WOMAN,
U+200D ZWJ,
U+1F467 GIRL,
U+200D ZWJ,
U+1F466 BOY

So it's very interestingly-encoded; the perfect target for a unit test. However, Swift doesn't seem to know how to treat it. Here's what I mean:

"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ".contains("๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ") // true
"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ".contains("๐Ÿ‘ฉ") // false
"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ".contains("\u{200D}") // false
"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ".contains("๐Ÿ‘ง") // false
"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ".contains("๐Ÿ‘ฆ") // true

So, Swift says it contains itself (good) and a boy (good!). But it then says it does not contain a woman, girl, or zero-width joiner. What's happening here? Why does Swift know it contains a boy but not a woman or girl? I could understand if it treated it as a single character and only recognized it containing itself, but the fact that it got one subcomponent and no others baffles me.

This does not change if I use something like "๐Ÿ‘ฉ".characters.first!.


Even more confounding is this:

let manual = "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}"
Array(manual.characters) // ["๐Ÿ‘ฉโ€", "๐Ÿ‘ฉโ€", "๐Ÿ‘งโ€", "๐Ÿ‘ฆ"]

Even though I placed the ZWJs in there, they aren't reflected in the character array. What followed was a little telling:

manual.contains("๐Ÿ‘ฉ") // false
manual.contains("๐Ÿ‘ง") // false
manual.contains("๐Ÿ‘ฆ") // true

So I get the same behavior with the character array... which is supremely annoying, since I know what the array looks like.

This also does not change if I use something like "๐Ÿ‘ฉ".characters.first!.

like image 777
Ky. Avatar asked Apr 25 '17 18:04

Ky.


People also ask

How do you write Emojis in Swift?

In any app with text editing capability, including Xcode, you can click on the Edit menu, then Emoji & Symbols. You'll see a panel with emoji. Choose any of them and they will appear in your code. Another way is to use the hotkey โŒƒโŒ˜Space (ctrl + cmd + space).

Are Emojis coded?

Yes, emojis are transmitted as code! And sometimes compatibility issues arise. Something's getting lost in translation between your device and the sender's device. In this post, we'll explore what's going on under the hood.


3 Answers

This has to do with how the String type works in Swift, and how the contains(_:) method works.

The '๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ ' is what's known as an emoji sequence, which is rendered as one visible character in a string. The sequence is made up of Character objects, and at the same time it is made up of UnicodeScalar objects.

If you check the character count of the string, you'll see that it is made up of four characters, while if you check the unicode scalar count, it will show you a different result:

print("๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ".characters.count)     // 4
print("๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ".unicodeScalars.count) // 7

Now, if you parse through the characters and print them, you'll see what seems like normal characters, but in fact the three first characters contain both an emoji as well as a zero-width joiner in their UnicodeScalarView:

for char in "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ".characters {
    print(char)

    let scalars = String(char).unicodeScalars.map({ String($0.value, radix: 16) })
    print(scalars)
}

// ๐Ÿ‘ฉโ€
// ["1f469", "200d"]
// ๐Ÿ‘ฉโ€
// ["1f469", "200d"]
// ๐Ÿ‘งโ€
// ["1f467", "200d"]
// ๐Ÿ‘ฆ
// ["1f466"]

As you can see, only the last character does not contain a zero-width joiner, so when using the contains(_:) method, it works as you'd expect. Since you aren't comparing against emoji containing zero-width joiners, the method won't find a match for any but the last character.

To expand on this, if you create a String which is composed of an emoji character ending with a zero-width joiner, and pass it to the contains(_:) method, it will also evaluate to false. This has to do with contains(_:) being the exact same as range(of:) != nil, which tries to find an exact match to the given argument. Since characters ending with a zero-width joiner form an incomplete sequence, the method tries to find a match for the argument while combining characters ending with a zero-width joiners into a complete sequence. This means that the method won't ever find a match if:

  1. the argument ends with a zero-width joiner, and
  2. the string to parse doesn't contain an incomplete sequence (i.e. ending with a zero-width joiner and not followed by a compatible character).

To demonstrate:

let s = "\u{1f469}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}" // ๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ

s.range(of: "\u{1f469}\u{200d}") != nil                            // false
s.range(of: "\u{1f469}\u{200d}\u{1f469}") != nil                   // false

However, since the comparison only looks ahead, you can find several other complete sequences within the string by working backwards:

s.range(of: "\u{1f466}") != nil                                    // true
s.range(of: "\u{1f467}\u{200d}\u{1f466}") != nil                   // true
s.range(of: "\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}") != nil  // true

// Same as the above:
s.contains("\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}")          // true

The easiest solution would be to provide a specific compare option to the range(of:options:range:locale:) method. The option String.CompareOptions.literal performs the comparison on an exact character-by-character equivalence. As a side note, what's meant by character here is not the Swift Character, but the UTF-16 representation of both the instance and comparison string โ€“ however, since String doesn't allow malformed UTF-16, this is essentially equivalent to comparing the Unicode scalar representation.

Here I've overloaded the Foundation method, so if you need the original one, rename this one or something:

extension String {
    func contains(_ string: String) -> Bool {
        return self.range(of: string, options: String.CompareOptions.literal) != nil
    }
}

Now the method works as it "should" with each character, even with incomplete sequences:

s.contains("๐Ÿ‘ฉ")          // true
s.contains("๐Ÿ‘ฉ\u{200d}")  // true
s.contains("\u{200d}")    // true
like image 128
xoudini Avatar answered Oct 23 '22 19:10

xoudini


The first problem is you're bridging to Foundation with contains (Swift's String is not a Collection), so this is NSString behavior, which I don't believe handles composed Emoji as powerfully as Swift. That said, Swift I believe is implementing Unicode 8 right now, which also needed revision around this situation in Unicode 10 (so this may all change when they implement Unicode 10; I haven't dug into whether it will or not).

To simplify thing, let's get rid of Foundation, and use Swift, which provides views that are more explicit. We'll start with characters:

"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ".characters.forEach { print($0) }
๐Ÿ‘ฉโ€
๐Ÿ‘ฉโ€
๐Ÿ‘งโ€
๐Ÿ‘ฆ

OK. That's what we expected. But it's a lie. Let's see what those characters really are.

"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ".characters.forEach { print(String($0).unicodeScalars.map{$0}) }
["\u{0001F469}", "\u{200D}"]
["\u{0001F469}", "\u{200D}"]
["\u{0001F467}", "\u{200D}"]
["\u{0001F466}"]

Ahโ€ฆ So it's ["๐Ÿ‘ฉZWJ", "๐Ÿ‘ฉZWJ", "๐Ÿ‘งZWJ", "๐Ÿ‘ฆ"]. That makes everything a bit more clear. ๐Ÿ‘ฉ is not a member of this list (it's "๐Ÿ‘ฉZWJ"), but ๐Ÿ‘ฆ is a member.

The problem is that Character is a "grapheme cluster," which composes things together (like attaching the ZWJ). What you're really searching for is a unicode scalar. And that works exactly as you're expecting:

"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ".unicodeScalars.contains("๐Ÿ‘ฉ") // true
"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ".unicodeScalars.contains("\u{200D}") // true
"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ".unicodeScalars.contains("๐Ÿ‘ง") // true
"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ".unicodeScalars.contains("๐Ÿ‘ฆ") // true

And of course we can also look for the actual character that is in there:

"๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ".characters.contains("๐Ÿ‘ฉ\u{200D}") // true

(This heavily duplicates Ben Leggiero's points. I posted this before noticing he'd answered. Leaving in case it is clearer to anyone.)

like image 114
Rob Napier Avatar answered Oct 23 '22 20:10

Rob Napier


It seems that Swift considers a ZWJ to be an extended grapheme cluster with the character immediately preceding it. We can see this when mapping the array of characters to their unicodeScalars:

Array(manual.characters).map { $0.description.unicodeScalars }

This prints the following from LLDB:

โ–ฟ 4 elements
  โ–ฟ 0 : StringUnicodeScalarView("๐Ÿ‘ฉโ€")
    - 0 : "\u{0001F469}"
    - 1 : "\u{200D}"
  โ–ฟ 1 : StringUnicodeScalarView("๐Ÿ‘ฉโ€")
    - 0 : "\u{0001F469}"
    - 1 : "\u{200D}"
  โ–ฟ 2 : StringUnicodeScalarView("๐Ÿ‘งโ€")
    - 0 : "\u{0001F467}"
    - 1 : "\u{200D}"
  โ–ฟ 3 : StringUnicodeScalarView("๐Ÿ‘ฆ")
    - 0 : "\u{0001F466}"

Additionally, .contains groups extended grapheme clusters into a single character. For instance, taking the hangul characters แ„’, แ…ก, and แ†ซ (which combine to make the Korean word for "one": แ„’แ…กแ†ซ):

"\u{1112}\u{1161}\u{11AB}".contains("\u{1112}") // false

This could not find แ„’ because the three codepoints are grouped into one cluster which acts as one character. Similarly, \u{1F469}\u{200D} (WOMAN ZWJ) is one cluster, which acts as one character.

like image 75
Ky. Avatar answered Oct 23 '22 20:10

Ky.