Spannable芸〜Unicode絵文字の表記長〜

ハロー、ノハナ社の @hiroyuki-seto です
[Android Advent Calendar 2018](https://qiita.com/advent-calendar/2018/android)の13日目です。
みなさん大好き[^1] 絵文字の話です。
[^1]: (要出典)

# 導入
今日は父親の誕生日なので、家族の話をしましょう。

`👪` のような表記の絵文字があったとします。
この絵文字は `String.length` で何文字になるでしょうか？
答えは `2` や `8` などの可能性があります。

## なぜこうなるのか
👪 (Family)はUnicodeで `U+1F46A` で定義されています。
これはサロゲート文字と呼ばれ、`U+D83D` と `U+DC6A` という2つの文字を合わせてできているので`String.length`の値は`2`になります。

また、Unicodeには複数の絵文字を組み合わせて1つの絵文字にする機能が定義されています。
Familyは👨(Man, `U+1F468`)とZWJ(`U+200D`)と👩(Woman, `U+1F469`)とZWJ(`U+200D`)と👦(Boy, `U+1F466`)を組み合わせても表現でき、この場合の`String.length`の値は`8`になります。

# 表記長を数えたい
弊社ではノハナというフォトブックサービスをやっています。
フォトブックの各ページでは写真にコメントをつけることができ、絵文字を入力することもできます。
コメントは32文字まで入力できるのですが、この「32文字」は `String.length` の値ではなく、当然目に見える文字数、**表記長**です。

## 頑張って数える
サロゲート文字については`Character.isHighSurrogate(Char)`や`Char.isHighSurrogate()`や`Character.isSurrogatePair(Char, Char)`などを使うと判定することができます。
`Character.codePointCount`を使うと、ハイサロゲートとロウサロゲートを合わせて1文字とカウントしてくれます。

複数の絵文字を組み合わせる件ですが、Unicodeでは[Recommended Emoji ZWJ Sequences](http://www.unicode.org/emoji/charts/emoji-zwj-sequences.html)というものを公開しています。
これを完全に再現する実装をすれば対応できます。
たかだか777個しかないので、全部実装できますね！
いや、やりたくないですね..

## BreakIterator
[BreakIterator](https://developer.android.com/reference/java/text/BreakIterator)というものがあります。文字と文字の境界を判定してくれるクラスです。

こんな感じにすると、大半の絵文字はちゃんとカウントしてくれます。

``` kotlin
fun CharSequence.getDisplayLength(): Int {
    val instance = BreakIterator.getCharacterInstance()
    instance.setText(text.text.toString())
    instance.first()

    var displayLength = 0
    while (instance.next() != BreakIterator.DONE) {
        displayLength++
    }
    return displayLength
}
```

しかし、💑 (Couple With Heart)を `U+1F469``U+200D``U+2764``U+FE0F``U+200D``U+1F468` で表記した場合に `2` が返ってきてしまいます。
これは色々と問題になります。

# EmojiCompatとSpannable
本題です。
[EmojiCompat](https://developer.android.com/guide/topics/ui/look-and-feel/emoji-compat)を使うと、比較的簡単に解決することができます。

EmojiCompatでは、文字列にEmojiSpanを設定することによって絵文字の表示を実現しています。
また、EmojiCompatはRecommended Emoji ZWJ Sequencesの内容を実装しており、複数の絵文字を結合した場合でも適切に表示することができます。
詳しい仕組みについては、[takahiromさんのDroidKaigiの資料](https://speakerdeck.com/takahirom/support-libraryfalsedownloadable-fontsyaemojicompatnidui-ying-sitaapuriwozuo-rou?slide=109)を参考にしてください。

👪の例で言うと、`U+1F46A`や`U+1F468``U+200D``U+1F469``U+200D``U+1F466`という文字列に1つのEmojiSpanを設定しています。
つまりEmojiSpanがかかっている文字列を1文字とカウントすれば絵文字の表記長を数えることができます。

コードにするとこんな感じです。

``` kotlin
fun CharSequence.getDisplayLength(): Int {
    val codePointCount = Character.codePointCount(this, 0, length)
    val text = EmojiCompat.get().process(SpannableString(this))
    if (text !is Spannable) {
        //文字長が0の場合など
        return codePointCount
    }

    val spans = text.getSpans(0, text.length, EmojiSpan::class.java)
    if (spans.isEmpty()) {
        //絵文字が含まれていない場合
        return codePointCount
    }

    val string = this.toString()
    var displayLength = 0
    var i = 0
    while (i < text.length) {
        val span = spans.firstOrNull { i == text.getSpanStart(it) }
        if (span == null) {
            //charCountの分だけを1文字とみなす
            val codePoint = string.codePointAt(i)
            val count = Character.charCount(codePoint)
            i += count
        } else {
            //EmojiSpanのかかっているindexまでを1文字とみなす
            i = text.getSpanEnd(span)
        }
        displayLength++
    }

    return displayLength
}
```

# 対応できないこと
## EmojiCompatが対応していない絵文字
EmojiCompatがSpanを設定できないので、当然文字が数えられません。
この記事を書いた時点では、Emoji 11には対応できていません。
Emoji11の機能である「髪の色を変更」した場合は正しく表記長をカウントできないと思います。

# おわりに
お分かりいただけたとおり、Spannableは非常に便利なクラスです。
ただし中毒性があり、黒魔術を生み出す可能性があるので用法用量を守って正しく使いましょう。

DroidKaigi2019では弊社の @tacke_jp がUnicode絵文字についてお話しするのでご期待ください。

# おまけ
Swift4では

``` swift
//👪
"\u{1F46A}".count //1が返ってくる
//複数の絵文字を組み合わせた👪
"\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F466}".count //1が返ってくる
```
:blush: